Translation Toggle
A per-message translation affordance — a pill that names the detected source language, crossfades between original and translation while the box tweens between their measured heights, re-aims at another target language from a menu, and stands in with a skeleton shaped by the source while a request is in flight.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/translation-toggle.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "TranslationToggle" component: the
"Translated from Japanese" affordance that belongs to ONE message. It renders
the message itself, so it owns the swap between the two languages. Icons come
from lucide-react, the language list from a Radix dropdown menu.
Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>
minus children; leftover props spread onto the root, which also carries
data-view and data-status.
- Language = { code: string (BCP 47); label: string (name in the interface
language); endonym?: string (name in its own language); dir?: "ltr" | "rtl" }.
- original: string — the message as written; always present, it is what the
toggle falls back to.
- translated?: string | null — the translation, or null when there is none.
- sourceLanguage: Language — detected UPSTREAM; the component never sniffs.
- targetLanguage: string — BCP 47 tag of `translated`, or of the request in
flight.
- languages?: Language[] (default []) — the menu options.
- status?: "idle" | "translating" | "error" (default "idle"). "idle" covers
both "done" and "never asked" — the difference is whether `translated` exists.
- view? / defaultView? / onViewChange? — "translated" | "original", the usual
controlled + uncontrolled triad, defaultView "translated".
- onTranslate?(language: Language) — the ONE request callback. It fires from a
menu pick, from the pill when nothing is translated yet, and from Try again.
The caller re-fetches and pushes targetLanguage / translated / status back
down; the component never fetches anything itself.
- controlsPosition?: "top" | "bottom" (default "bottom") — swaps DOM order, not
just visual order, so the reading order stays right.
- renderText?(text, { language, view }) — hand the body to your own Markdown /
mention / highlight renderer.
- labels?: Partial<Labels> — every visible string plus announce(ctx), merged
over the English defaults so a caller can override one and keep the rest.
Behavior — one button, four modes
- hasTranslationPane = `translated` is non-empty OR status === "translating"
(a skeleton counts as a pane). activeView = hasTranslationPane ? view :
"original", so an empty pane is unreachable — not by a click, not by a stale
controlled `view`.
- With a pane, the pill is a TOGGLE: its visible label is state plus action
("Translated from Japanese · See original"), and aria-pressed means "the
translation is on screen" — pressed must agree with what the control is
called, never with which text happens to be underneath.
- With no pane, the same button becomes the REQUEST ("Translate into English"),
and after a failure it becomes "Try again". aria-pressed comes OFF entirely
there — a one-shot command has no pressed state, and announcing "toggle
button, not pressed" describes a state the control does not have.
- status="translating": the pill icon becomes a spinner, the state line reads
"Translating into French…", and the checked row in the menu gets a spinner
too, because that row is the request.
- status="error": one destructive line under the pill. Retry is rendered
EXACTLY once — inside that line while a translation is still readable, and as
the pill itself when there is nothing to toggle. Two retry buttons for one
request is a bug, not a convenience.
Behavior — crossfade that does not jump the page
- Both panes occupy the SAME grid cell (col-start-1 row-start-1) and are
self-start items. self-start is load-bearing: with the default stretch, a box
height set from a measurement would stretch the panes and feed straight back
into the ResizeObserver.
- Measure both panes with one ResizeObserver started from an isomorphic layout
effect (measure once synchronously as well, so the first paint is already
correct), then set the box height to the ACTIVE pane's measurement and
transition it. Disconnect the observer on unmount.
- A measurement of 0 means "not measured yet" (first paint, or no
ResizeObserver): fall back to auto — which is the taller of the two panes —
never to a collapsed box. Turn the height transition on only once a
measurement exists; auto → px does not interpolate anyway.
- Crossfade both panes with opacity. Keep a `settledView` that lags the active
view by the fade duration: while the two disagree BOTH panes are painted (one
fading out); when they agree the outgoing pane goes visibility:hidden.
Without that settle step, a drag-select picks up both languages of the same
sentence and the clipboard gets a bilingual mess. Clear the timer on unmount
and on every re-toggle.
- prefers-reduced-motion: no opacity transition, no height transition, and the
settle is DERIVED rather than timed, so the swap is atomic — exactly one
language on screen at every instant. Nothing about the function changes.
Behavior — who is allowed to move the reader
- A menu pick is an explicit ask to READ that language: set the view to
"translated", then fire onTranslate. A translation that merely ARRIVES never
moves anyone — a reader in the middle of the original stays in the original.
- One-shot: picking the language already on screen returns without firing, so
re-opening the menu cannot cost an API call. Try again always fires.
- If targetLanguage is not in `languages`, fold it in at the top of the list so
the radio group still has a checked row, and let the pill name it by its tag.
- Fewer than two menu entries: drop the trigger and the divider entirely. A
menu with one choice is a lie.
Behavior — language, direction, screen readers
- Each pane carries lang={language.code} and dir={language.dir ?? "auto"}.
"auto" infers direction from the first strong character, which is the honest
default for text nobody on your team has read; the lang attribute is what
makes a screen reader switch voice instead of reading French with an English
one, and what lets the browser hyphenate correctly.
- The content wrapper carries aria-busy while a request is in flight; the
inactive pane is aria-hidden, pointer-events-none and select-none.
- ONE polite sr-only role="status" line, derived from props — no effect, no
timer, nothing to leak: "Showing the original in Japanese." / "Showing the
translation in English." / "Translating into French." / "Translation into
French failed." Never make the message text itself a live region, or the
whole message is read twice on every toggle.
- Skeleton bars are aria-hidden: the live region already says what is going on.
Behavior — the skeleton
- While translating with no text yet, render 2–5 muted bars whose count is
ceil(source length / 46) and whose widths come from a FIXED table, last bar
short. The placeholder is shaped by the source, so what lands changes the
words and not the size of the box; the short last bar is what makes a stack
of bars read as a paragraph instead of a table. No Math.random anywhere — the
server and the first client frame have to agree.
- Keep a stale translation on screen only while it still matches
targetLanguage. Requesting a DIFFERENT language should clear `translated` to
null, otherwise the pane keeps text in one language under the lang attribute
of another.
Rendering & styling
- Semantic tokens only: bg-card, border, text-muted-foreground, text-foreground
for the pill; hover:bg-accent + hover:text-accent-foreground for its
segments; bg-muted bars with a via-foreground/15 sweep for the skeleton;
text-destructive for the error line; ring-ring for focus. No hex, no rgb(),
no oklch(), so a token swap restyles it and dark mode is free.
- The pill is one bordered rounded-full group of two segments split by a 1px
divider: the toggle (rounded-s-full) and the menu trigger (rounded-e-full,
showing the target tag in caps plus a chevron that rotates while open). Use
LOGICAL radii so the pill is still correct in an RTL interface.
- Both segments are real <button>s with focus-visible:ring-2 ring-ring and z-10
while focused, so the ring is never clipped by its neighbour. The pair is a
role="group" with an accessible name.
- The sweep keyframes ship inside the component through a React 19 hoisted
<style href precedence>, so there is no Tailwind config edit and duplicate
instances dedupe by href.
- Panes are text-sm / leading-relaxed / whitespace-pre-wrap / wrap-anywhere, so
an unbreakable URL cannot widen the message column.
Customization levers
- Crossfade duration: the CROSSFADE_MS constant and the duration-200 on both
panes are one knob in two places — 120ms reads instant, 320ms reads
deliberate. Keep them equal, or the outgoing pane disappears mid-fade.
- Density: the pill's py-1 / text-xs and the root's gap-2 are the only spacing
knobs. For a tighter pill, drop the action word and let aria-pressed plus the
state line carry the meaning.
- controlsPosition="top" turns it into a mail-client translation bar above the
body; "bottom" is the chat-transcript placement.
- Skeleton shape: CHARS_PER_LINE (46) and the width table decide how the
placeholder reads. One bar plus a spinner is plenty for one-line messages.
- Menu: swap the endonym-first row for label-only rows, group by region with a
separator, add usage-ordered "recent languages" at the top, or add a "Detect
automatically" entry whose code is your own sentinel.
- Labels: pass `labels` to localise the chrome itself, announce() included —
the whole point of this component is that it is used by people who may not
read the interface language.
- Height animation: drop the ResizeObserver and let the box be auto if your
messages are one line. Keep it the moment the two languages differ in length,
which Japanese → German always does.
- Body rendering: renderText lets the same component host Markdown, mentions or
highlighted search hits; both panes go through it, so the two languages can
never drift into different renderers.Concepts
- Detected source, never guessed — the pill only reports what the caller detected upstream (
sourceLanguage); the component has no opinion about what language a string is in, which is why it can never be confidently wrong about it. - Two panes, one cell — the original and the translation stack in the same grid cell and crossfade, while the box tweens between their two measured heights; that is what keeps a toggle from shoving the rest of the conversation up or down.
- Settle after the fade — once the crossfade ends the outgoing pane is taken out with
visibility:hidden, so a drag-select can never pick up the same sentence twice in two languages; under reduced motion the settle is derived instead of timed and the swap is atomic. - Source-shaped skeleton — the placeholder's line count comes from the length of the original, so the arriving translation changes the words and not the size of the box; the widths are a fixed table, never random, so server and client render the same bars.
- Explicit ask beats arrival — picking a language from the menu moves the reader to the translation because they asked for it; a translation that merely finishes loading never does, since yanking someone out of the original mid-sentence is the one thing this affordance must not do.
- One-shot pick — re-selecting the language already on screen returns without firing
onTranslate, so browsing the menu costs nothing; Try again is the one entry point that always re-fires, which is what makes a failed request recoverable.
Read Aloud Button
A per-message listen toggle — progress ring, tiny equalizer and a speed pill — driven by the browser's speech engine or by your own TTS callbacks.
AI Disclaimer
The 'AI can make mistakes. Check important info.' line — inline, banner and details-popover chromes, role=note so it never interrupts, and a one-shot dismissal that hands persistence to the app.