AI

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  Briefcase,
  Check,
  CornerDownLeft,
  Feather,
  Loader2,
  Maximize2,
  Scissors,
  SlidersHorizontal,
  Smile,
} from "lucide-react"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/tone-selector.json

Prompt

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-disabled instead of disabled and stays in the arrow order, because the explanation of why it is locked lives in its tooltip and a disabled button 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.

On This Page