Profile Visibility
Who can see each profile field: everyone, connections or only me per row, a preview-as switch, and bulk set all.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/visibility-settings.json'use client'
import { useState } from 'react'
import './visibility-settings.css'
export interface VisField { id: string; label: string; value: string; level: 0 | 1 | 2; locked?: boolean }
export interface VisibilitySettingsProps {
fields: VisField[]
previewAs?: 0 | 1
onChange?: (f: VisField[]) => void
fill?: 'dark' | 'light'
className?: string
}
const LV = ['Everyone', 'Connections', 'Only me']
const Icon = ({ l }: { l: number }) => <svg viewBox="0 0 16 16" aria-hidden>{l === 0 ? <><circle cx="8" cy="8" r="6" /><path d="M2 8h12M8 2c2 2 2 10 0 12M8 2c-2 2-2 10 0 12" /></> : l === 1 ? <><circle cx="6" cy="6" r="2.3" /><circle cx="11" cy="7" r="1.8" /><path d="M2 13c.5-2.3 2-3.5 4-3.5s3.5 1.2 4 3.5M10 10.2c1.8-.3 3.4.8 4 2.8" /></> : <><rect x="3.5" y="7" width="9" height="6.5" rx="1.5" /><path d="M5.5 7V5a2.5 2.5 0 0 1 5 0v2" /></>}</svg>
/** Per-field profile visibility with audience preview. */
export function VisibilitySettings({ fields, previewAs = 0, onChange, fill = 'dark', className }: VisibilitySettingsProps) {
const [fs, setFs] = useState(fields)
const [as, setAs] = useState<0 | 1>(previewAs)
const commit = (n: VisField[]) => { setFs(n); onChange?.(n) }
const name = fs.find((f) => f.locked)
return (
<div className={['sd-vis', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-vis__as"><span>Preview as</span><div role="radiogroup" aria-label="Preview as">{([0, 1] as const).map((a) => <button key={a} type="button" role="radio" aria-checked={as === a} onClick={() => setAs(a)}>{LV[a]}</button>)}</div></div>
<div className="sd-vis__pv" aria-live="polite" aria-label={`Profile as seen by ${LV[as].toLowerCase()}`}>
<small>Seen by {LV[as].toLowerCase()}</small>
<b>{name?.value}</b>
{fs.filter((f) => !f.locked).map((f) => f.level <= as ? <span key={f.id}>{f.value}</span> : null)}
{fs.filter((f) => !f.locked && f.level > as).length > 0 && <span data-hid>{fs.filter((f) => !f.locked && f.level > as).length} fields hidden</span>}
</div>
<ul>
{fs.map((f) => (
<li key={f.id}>
<span>{f.label}<small>{f.locked ? 'Always public' : f.value}</small></span>
<div className="sd-vis__lv" role="radiogroup" aria-label={`${f.label} visible to`}>
{[0, 1, 2].map((l) => <button key={l} type="button" role="radio" aria-checked={f.level === l} aria-label={LV[l]} title={LV[l]} disabled={f.locked && l > 0} onClick={() => commit(fs.map((x) => (x.id === f.id ? { ...x, level: l as 0 | 1 | 2 } : x)))}><Icon l={l} /></button>)}
</div>
</li>
))}
</ul>
<div className="sd-vis__all"><span>Set all to</span>{[0, 1, 2].map((l) => <button key={l} type="button" onClick={() => commit(fs.map((x) => (x.locked ? x : { ...x, level: l as 0 | 1 | 2 })))}>{LV[l]}</button>)}</div>
</div>
)
}.sd-vis { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-vis[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-vis :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-vis :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-vis .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-vis { width: min(100%, 340px); padding: 14px; grid-template-columns: minmax(0, 1fr); border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); display: grid; gap: 10px; }
.sd-vis__pv { padding: 12px; border-radius: 14px; background: var(--soft); display: grid; gap: 3px; min-height: 96px; }
.sd-vis__pv small { font-size: 11px; color: var(--mute); }
.sd-vis__pv b { font-size: 15px; }
.sd-vis__pv span { font-size: 12.5px; }
.sd-vis__pv span[data-hid] { color: var(--mute); font-style: italic; }
.sd-vis__as { display: flex; align-items: center; justify-content: space-between; font-size: 12px; color: var(--mute); }
.sd-vis__as div { display: flex; padding: 2px; border-radius: 9px; background: var(--soft); }
.sd-vis__as div button { padding: 4px 9px; border-radius: 7px; font-size: 11.5px; color: var(--mute) !important; }
.sd-vis__as div button[aria-checked='true'] { background: var(--bg) !important; color: var(--fg) !important; box-shadow: inset 0 0 0 1px var(--line); }
.sd-vis ul { list-style: none; margin: 0; padding: 0; }
.sd-vis li { display: flex; align-items: center; gap: 8px; padding: 8px 0; border-bottom: 1px solid var(--line); }
.sd-vis li > span { flex: 1; min-width: 0; font-size: 13px; }
.sd-vis li > span small { display: block; color: var(--mute); font-size: 11.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sd-vis__lv { display: flex; gap: 2px; }
.sd-vis__lv button { width: 30px; height: 28px; border-radius: 7px; display: grid; place-items: center; box-shadow: inset 0 0 0 1px var(--line); }
.sd-vis__lv button[aria-checked='true'] { background: var(--strong) !important; color: var(--on) !important; box-shadow: none; }
.sd-vis__lv button:disabled { opacity: 0.35; }
.sd-vis__lv svg { width: 14px; height: 14px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.sd-vis__all { display: flex; gap: 5px; align-items: center; font-size: 12px; color: var(--mute); }
.sd-vis__all button { padding: 5px 10px; border-radius: 99px; font-size: 11.5px; box-shadow: inset 0 0 0 1px var(--line); color: var(--fg) !important; }
@media (prefers-reduced-motion: reduce) { .sd-vis *, .sd-vis *::before, .sd-vis *::after { animation: none !important; transition: none !important; } }Overview
Each field has a three-way selector. Preview as switches the card at the top to what that audience would see, so the effect of a choice is visible right away.
Set all applies one level to every field at once; locked fields such as your name stay public.
When to use
- Profile and account privacy settings.
When not to use
- Fine-grained per-person sharing: use a share dialog.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/visibility-settings.json$
npx shadcn@latest add https://sundaeui.dev/r/visibility-settings.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/visibility-settings.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/visibility-settings.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy visibility-settings.tsx and visibility-settings.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { VisibilitySettings } from '@/components/sundae/visibility-settings'
<VisibilitySettings />Props
| Prop | Type | Default |
|---|---|---|
fields*Fields; 0 everyone, 1 connections, 2 only me. | { id: string; label: string; value: string; level: 0 | 1 | 2; locked?: boolean }[] | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Each selector is a radiogroup labelled with the field.
- The preview states which audience it shows.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-vis; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Profile Visibility free?
- Yes. Profile Visibility is free for personal and commercial projects, with full source on this page.
- How do I install Profile Visibility?
- Run npx shadcn@latest add https://sundaeui.dev/r/visibility-settings.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Profile Visibility support keyboard and screen readers?
- Each selector is a radiogroup labelled with the field. The preview states which audience it shows.
- Does Profile Visibility respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →Data Export
Forms · RefinedCheckbox Tree
Forms · RefinedAvatar Upload
Forms · RefinedNotification Matrix
Forms · Refined- Pro
Shortcut Recorder
Forms · Refined Status Picker
Forms · Refined