Status Picker
Set your status from presets or custom text, choose when it clears, and see it on your avatar badge right away.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/status-picker.json'use client'
import { useState } from 'react'
import './status-picker.css'
export interface StatusPreset { icon: string; text: string; clear: string }
export type Presence = 'active' | 'busy' | 'away'
export interface StatusPickerProps {
name?: string
presets?: StatusPreset[]
defaultPresence?: Presence
onSave?: (s: { text: string; icon: string; clear: string; presence: Presence }) => void
fill?: 'dark' | 'light'
className?: string
}
const DEF: StatusPreset[] = [
{ icon: '◷', text: 'In a meeting', clear: '1 hour' },
{ icon: '◎', text: 'Focusing', clear: '2 hours' },
{ icon: '↗', text: 'Commuting', clear: '30 minutes' },
{ icon: '☾', text: 'Out for the day', clear: 'Today' },
]
const CLEAR = ['30 minutes', '1 hour', '2 hours', 'Today', 'This week', 'Don’t clear']
/** Status message, duration and presence. */
export function StatusPicker({ name = 'Maya Kim', presets = DEF, defaultPresence = 'busy', onSave, fill = 'dark', className }: StatusPickerProps) {
const [text, setText] = useState('')
const [icon, setIcon] = useState('✎')
const [clear, setClear] = useState('1 hour')
const [presence, setPresence] = useState<Presence>(defaultPresence)
const [saved, setSaved] = useState(false)
return (
<div className={['sd-stt', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-stt__me">
<span className="sd-stt__av" aria-hidden>{name.split(' ').map((w) => w[0]).join('')}<span className="sd-stt__dot" data-p={presence} /></span>
<span><b>{name}</b><span><i key={text + presence}>{text ? `${icon} ${text}` : presence === 'active' ? 'Available' : presence === 'busy' ? 'Busy' : 'Away'}</i></span></span>
</div>
<div className="sd-stt__field">
<span className="sd-stt__ic" aria-hidden>{icon}</span>
<input aria-label="Status" placeholder="What’s your status?" value={text} maxLength={60} onChange={(e) => { setText(e.target.value); setSaved(false) }} />
{text && <button type="button" className="sd-stt__clr" aria-label="Clear status" onClick={() => { setText(''); setIcon('✎') }}>×</button>}
</div>
{!text && (
<div className="sd-stt__pre">
{presets.map((p) => (
<button key={p.text} type="button" className="sd-stt__p" onClick={() => { setText(p.text); setIcon(p.icon); setClear(p.clear); setPresence(p.text === 'Out for the day' ? 'away' : 'busy') }}>
<span aria-hidden>{p.icon}</span><span>{p.text}</span><span>{p.clear}</span>
</button>
))}
</div>
)}
<div className="sd-stt__row"><label htmlFor="stt-clear">Clear after</label><select id="stt-clear" value={clear} onChange={(e) => setClear(e.target.value)}>{CLEAR.map((c) => <option key={c}>{c}</option>)}</select></div>
<div className="sd-stt__row">
<span id="stt-pres">Show as</span>
<div className="sd-stt__pres" role="radiogroup" aria-labelledby="stt-pres">
{(['active', 'busy', 'away'] as Presence[]).map((p) => <button key={p} type="button" role="radio" aria-checked={presence === p} onClick={() => setPresence(p)}>{p[0].toUpperCase() + p.slice(1)}</button>)}
</div>
</div>
<button type="button" className="sd-stt__save" onClick={() => { setSaved(true); onSave?.({ text, icon, clear, presence }) }}>{saved ? 'Saved' : 'Save status'}</button>
<span className="sr" aria-live="polite">{saved ? 'Status saved' : ''}</span>
</div>
)
}.sd-stt { --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-stt[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-stt button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-stt :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-stt .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-stt { width: min(100%, 320px); padding: 14px; border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-stt__me { display: flex; align-items: center; gap: 12px; padding-bottom: 12px; border-bottom: 1px solid var(--line); }
.sd-stt__av { position: relative; width: 40px; height: 40px; border-radius: 99px; background: var(--soft); display: grid; place-items: center; font-weight: 650; font-size: 14px; }
.sd-stt__dot { position: absolute; right: -1px; bottom: -1px; width: 13px; height: 13px; border-radius: 99px; box-shadow: 0 0 0 3px var(--bg); transition: background-color 200ms, box-shadow 200ms; }
.sd-stt__dot[data-p='active'] { background: var(--fg); }
.sd-stt__dot[data-p='busy'] { background: var(--bg); box-shadow: 0 0 0 3px var(--bg), inset 0 0 0 3.5px var(--fg); }
.sd-stt__dot[data-p='away'] { background: var(--mute); }
.sd-stt__me b { display: block; font-size: 14px; }
.sd-stt__me span { font-size: 12.5px; color: var(--mute); }
.sd-stt__me span i { font-style: normal; animation: sd-stt-in 220ms ease; display: inline-block; }
@keyframes sd-stt-in { from { opacity: 0; transform: translateY(3px); } }
.sd-stt__field { display: flex; align-items: center; gap: 8px; margin-top: 12px; height: 42px; padding: 0 10px; border-radius: 11px; box-shadow: inset 0 0 0 1px var(--line); }
.sd-stt__field:focus-within { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-stt__field input { flex: 1; min-width: 0; border: 0; outline: 0; background: none; color: var(--fg); font: inherit; font-size: 14px; }
.sd-stt__ic { width: 22px; text-align: center; font-size: 15px; }
.sd-stt__clr { color: var(--mute); font-size: 16px; width: 22px; }
.sd-stt__pre { display: grid; gap: 2px; margin-top: 8px; }
.sd-stt__p { display: flex; align-items: center; gap: 10px; height: 36px; padding: 0 8px; border-radius: 9px; font-size: 13.5px; text-align: left; }
.sd-stt__p:hover { background: var(--soft) !important; }
.sd-stt__p span:last-child { margin-left: auto; color: var(--mute); font-size: 12px; }
.sd-stt__row { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-top: 10px; font-size: 13px; }
.sd-stt__row select { height: 32px; padding: 0 8px; border-radius: 8px; border: 0; background: var(--soft); color: var(--fg); font: inherit; font-size: 13px; }
.sd-stt__pres { display: flex; gap: 4px; }
.sd-stt__pres button { height: 30px; padding: 0 10px; border-radius: 99px; font-size: 12px; box-shadow: inset 0 0 0 1px var(--line); color: var(--mute); }
.sd-stt__pres button[aria-checked='true'] { background: var(--strong) !important; color: var(--on); box-shadow: none; }
.sd-stt__save { margin-top: 12px; width: 100%; height: 40px; border-radius: 11px; background: var(--strong) !important; color: var(--on) !important; font-weight: 600; font-size: 13.5px; }
@media (prefers-reduced-motion: reduce) { .sd-stt *, .sd-stt *::before, .sd-stt *::after { animation: none !important; transition: none !important; } }Overview
Presets like In a meeting or Focusing fill the field with a symbol and default duration. Custom text works too, and Clear after picks from 30 minutes to Don’t clear.
The avatar preview at the top updates live, and the badge shows Away, Busy or Available.
When to use
- Team chat, collaboration and support tools.
When not to use
- Consumer apps where presence is a privacy risk.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/status-picker.json$
npx shadcn@latest add https://sundaeui.dev/r/status-picker.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/status-picker.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/status-picker.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 status-picker.tsx and status-picker.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 { StatusPicker } from '@/components/sundae/status-picker'
<StatusPicker />Props
| Prop | Type | Default |
|---|---|---|
presetsPreset statuses. | { icon: string; text: string; clear: string }[] | — |
onSaveCalled on save. | (s: { text: string; icon: string; clear: string; presence: string }) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Presets are buttons; presence is a radiogroup; Clear after is a native select.
- Saving is announced.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-stt; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Status Picker free?
- Yes. Status Picker is free for personal and commercial projects, with full source on this page.
- How do I install Status Picker?
- Run npx shadcn@latest add https://sundaeui.dev/r/status-picker.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Status Picker support keyboard and screen readers?
- Presets are buttons; presence is a radiogroup; Clear after is a native select. Saving is announced.
- Does Status Picker respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →Avatar Upload
Forms · RefinedStatus Pill
Forms · RefinedAvailability Grid
Forms · RefinedStandup Notes
Forms · RefinedProfile Visibility
Forms · RefinedOTP Field
Forms · Refined