AI

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ChevronDown, Languages, LoaderCircle, RotateCw, TriangleAlert } from "lucide-react"
import { cn } from "@/lib/utils"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuLabel,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useControllableState } from "@/registry/hooks/use-controllable-state"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/translation-toggle.json

Prompt

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.

On This Page