Tone Selector
A radio-group row of rewrite tones where every pill carries a sample phrase, and the custom tone applies only once its free-text instruction is committed.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/tone-selector.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ToneSelector" component (shadcn Button /
Input / Tooltip, lucide-react icons): a pill row of rewrite tones with radio
group semantics, a sample phrase per tone, and a free-text "custom" tone.
Copy stays neutral so the same control works in a composer ("write it like
this") and on a finished answer ("say that again like this").
Contract
- Tone = { id, label, hint?, sample?, instruction?, icon?, disabled? }.
hint = one clause on what the tone does; sample = the SAME example sentence
rendered in this tone; instruction = what the model is told (falls back to
hint, then label).
- Export DEFAULT_TONES (professional / friendly / concise / expanded / simple)
and CUSTOM_TONE_ID = "custom".
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>
minus defaultValue and onChange.
- tones?: Tone[] (default DEFAULT_TONES)
- value?: string | null — controlled id of the APPLIED tone; defaultValue?:
string | null (default null) for the uncontrolled case.
- onTone?: (choice: { id, label, instruction, custom }) => void — fires only
when a tone is actually applied.
- activation?: "auto" | "manual" (default "auto")
- layout?: "row" | "grid" (default "row"); size?: "sm" | "md" (default "md")
- allowCustom?: boolean (default true); customLabel?: string (default "Custom")
- customText?: string + onCustomTextChange?: (t: string) => void — optional
control of the free-text field; customPlaceholder?, maxCustomLength?: number
(default 120)
- showSamples?: boolean (default true); label?: string (default "Tone")
- disabled?: boolean; busy?: boolean — a rewrite is running upstream.
Behavior — selection
- role="radiogroup" with aria-label={label}; each pill is a <button
type="button" role="radio" aria-checked>. Roving tabindex: exactly one pill
is tabbable — the focused one, else the applied one, else the first.
- Arrow Right/Down and Left/Up wrap around the row; Home / End jump to the
ends. Every arrow key calls preventDefault so the page never scrolls.
- activation="auto": moving focus applies the tone as it lands (classic radio
group). activation="manual": arrows move focus only and Enter / Space / click
commits — use it when applying a tone costs a model call, so arrowing across
five pills does not fire five rewrites.
- onTone hands back the instruction ready to append to a prompt, not just an
id: { id, label, instruction, custom }. Consumers should not have to keep a
second lookup table in sync with the labels the user reads.
Behavior — the custom tone
- allowCustom appends a synthetic tone with CUSTOM_TONE_ID. Picking it applies
NOTHING: an empty instruction is not a tone. It only "arms" — the pill turns
dashed/outlined and a labelled free-text field opens under the row.
- Because armed is not applied, keep it as its own state instead of writing
CUSTOM_TONE_ID into the applied value; a controlled `value` that moves to any
other tone disarms it, so two pills can never look picked at once. Derive
that during render from a previous-props state, not in an effect.
- Focus policy: clicking / Enter / Space on the custom pill moves the caret
into the field; arrow keys landing on it under auto activation only arm it.
Arrow navigation that yanks focus into a text input traps the user mid-row.
- Enter in the field applies and preventDefaults (the field often sits inside a
composer <form>, and a stray submit would send the draft). An Apply button
does the same with the mouse and is disabled while the trimmed text is empty
or already applied.
- Escape restores the last applied wording and returns focus to the pill; if
nothing was ever applied under it, Escape also disarms, which simply reveals
the tone actually in force. Trim before comparing and before emitting; clip
input to maxCustomLength and show a live counter that turns destructive at
the limit.
Behavior — unavailable, busy, confirmation
- A disabled tone renders with aria-disabled, NOT the disabled attribute, and
stays in the arrow order: the tooltip explaining why it is out of reach must
be reachable by keyboard. Selecting it is a no-op.
- busy puts aria-busy on the group, spins a loader in the applied pill and
makes every handler a no-op — again via aria-disabled, so the currently
focused pill does not lose focus to <body> the moment a rewrite starts.
- Applying flashes a one-shot confirmation on the pill for ~1.4s: a check
replaces the icon and a ring scales out once. Drive it from a single state
object { id, seq, message } and one effect keyed on seq, so a new apply
cancels the previous timer and unmount clears it — no timer refs to leak.
- Announce applications through an sr-only role="status" aria-live="polite"
region ("Tone set to Concise" / "Custom tone applied: …"), and key the inner
node by seq: identical text is not a change a live region reports, a replaced
node is — that is what makes re-applying the same tone audible.
Rendering & styling
- Semantic tokens only: bg-primary / text-primary-foreground for the applied
pill, border-primary + bg-primary/10 + border-dashed for the armed custom
pill, bg-card + text-muted-foreground + hover:bg-muted for idle, ring-ring
focus-visible ring with a ring-offset-background offset, bg-muted/40 for the
custom panel, text-destructive for the counter at the limit. No hex, no
rgb(), no oklch().
- Pills are rounded-full, one row with flex-wrap ("row") or an auto-fit grid of
minmax(min(8.5rem,100%),1fr) cells ("grid"); labels truncate, they never
wrap mid-pill.
- The tooltip shows label, hint and the sample in quotes. Write every sample as
the SAME sentence in a different register — a row where each sample talks
about something else teaches nothing.
- The confirmation keyframes ship with the component in a React 19 hoisted
<style href precedence> (instances dedupe by href) and the halo is
motion-reduce:hidden; the loader is motion-reduce:animate-none. Under reduced
motion the control loses the flourish and nothing else.
- Merge consumer classes with cn(); spread the rest of the props on the root so
the group can be positioned by its parent.
Customization levers
- Tone set: DEFAULT_TONES is a plain array — reorder it, cut it to three for a
toolbar, or add domain tones (Apologetic, Technical, Marketing) with their
own sample + instruction. Nothing else in the component knows the ids.
- activation: "auto" for free local formatting, "manual" when each apply costs
a request. Pair "manual" with busy for the round trip.
- Density: size="sm" for a toolbar row, layout="grid" for a settings panel or a
narrow sidebar; drop showSamples when the labels are self-evident and the
tooltip would only add noise.
- Tooltip delay (250ms) and the sample copy are the whole teaching layer —
raise the delay for a calmer row, or swap TooltipContent for a Popover if
your samples grow past one sentence.
- Custom tone: maxCustomLength bounds what reaches the prompt; customText +
onCustomTextChange let a parent persist the last instruction across sessions;
allowCustom={false} removes the panel entirely.
- Confirmation: APPLIED_MS (1400) and the halo keyframes are the only motion.
Set them to zero effect by deleting the flash branch — the live region still
announces, so the accessible behaviour does not depend on the animation.
- Payload shape: extend ToneChoice with your own fields (e.g. a temperature or
a target length) inside the tone table rather than at the call site, so the
pill and the prompt keep telling the same story.Concepts
- Armed vs applied — picking the custom pill only arms it: the panel opens and the pill goes dashed, but no instruction exists yet, so nothing is emitted. "Chosen" and "in force" are different states and the control refuses to let one masquerade as the other.
- Auto vs manual activation — a radio group normally applies as the arrows move; when each apply is a model call, that turns one keyboard sweep into five rewrites, so
activation="manual"separates focus from commitment (the WAI-ARIA tabs pattern, applied to a cost boundary). - Sample as comparison — every tooltip renders the same sentence in its own register; the row teaches by contrast, which only works if the content is held constant and the tone is the only variable.
- Instruction payload — the callback hands back the words the model will be sent, not just an id, so the label the user read and the prompt the model got cannot drift apart in two separate tables.
- Focusable unavailable — a locked tone keeps
aria-disabledinstead ofdisabledand stays in the arrow order, because the explanation of why it is locked lives in its tooltip and adisabledbutton can neither be focused nor hovered by a keyboard user. - One-shot confirmation — the applied flash is a single state bump with one effect-owned timer: a new apply supersedes the previous one, unmount clears it, and the polite live region is keyed by the same counter so re-applying the same tone is still announced.
Persona Picker
Choose the assistant persona a turn runs under — seeded avatar cards or a compact combobox, capability tags, the system-prompt fragment each persona quietly injects, unavailable rows that say why, and four data states.
Sampling Controls
A controlled panel for temperature, top-p, max tokens and the penalties — editable value badges, a worded creative-to-precise scale, per-parameter revert, and a nudge when temperature and top-p are tuned together.