Babelize Elements
ComponentsTranslation

Translation Widget

Side-by-side translation card with language pickers, swap, copy, character count, and a loading state. RTL-aware and themed with shadcn tokens.

72 / 500 characters

Installation

npx shadcn@latest add @babelize/translation-widget

Usage

"use client";

import { useState } from "react";
import { TranslationWidget } from "@/components/ui/translation-widget";

export function Translate() {
  const [text, setText] = useState("");

  return (
    <TranslationWidget
      sourceLocale="en"
      targetLocale="fr"
      sourceText="Hello, world!"
      targetText={text} // omit for uncontrolled
      onTargetChange={setText}
    />
  );
}

With language pickers and the swap button:

"use client";

import { useState } from "react";
import { TranslationWidget } from "@/components/ui/translation-widget";

const locales = [{ code: "en" }, { code: "fr" }, { code: "ar" }];

export function Translate() {
  const [source, setSource] = useState("Hello, world!");
  const [target, setTarget] = useState("Bonjour, le monde !");
  const [sourceLocale, setSourceLocale] = useState("en");
  const [targetLocale, setTargetLocale] = useState("fr");

  return (
    <TranslationWidget
      locales={locales}
      sourceLocale={sourceLocale}
      targetLocale={targetLocale}
      onSourceLocaleChange={setSourceLocale}
      onTargetLocaleChange={setTargetLocale}
      sourceText={source}
      onSourceChange={setSource}
      targetText={target}
      onTargetChange={setTarget}
      onSwap={() => console.log("swapped")}
    />
  );
}

Props

PropTypeRequiredDescription
localesLocale[]NoLocales for the per-pane language pickers; omit to show static chips
sourceLocalestringNoBCP 47 code of the source locale (e.g. "en"); provide to control it
defaultSourceLocalestringNoInitial source locale when uncontrolled (default: locales[0], else "en")
onSourceLocaleChange(code: string) => voidNoCallback when the source locale changes
targetLocalestringNoBCP 47 code of the target locale (e.g. "fr", "ar"); provide to control it
defaultTargetLocalestringNoInitial target locale when uncontrolled (default: locales[1], else "en")
onTargetLocaleChange(code: string) => voidNoCallback when the target locale changes
sourceTextstringNoSource text — pass this to control the source pane
defaultSourceTextstringNoInitial source text when uncontrolled
onSourceChange(text: string) => voidNoCallback when the source text changes; makes the pane editable
targetTextstringNoTarget text — pass this to control the target pane (read-only without onTargetChange)
defaultTargetTextstringNoInitial target text when uncontrolled
onTargetChange(text: string) => voidNoCallback when the target text changes
onSwap() => voidNoCalled after the swap button exchanges locales and texts
sourcePlaceholderstringNoPlaceholder for the source pane
targetPlaceholderstringNoPlaceholder for the target pane
sourceLabelstringNoAccessible label for the source pane (default: "Source text")
targetLabelstringNoAccessible label for the target pane (default: "Target text")
readOnlySourcebooleanNoKeep the source pane read-only even with onSourceChange
showCountbooleanNoShow the source character count (default: true)
maxLengthnumberNoCharacter limit for both panes; the count shows n / max
showClearbooleanNoShow a clear button under an editable source (default: true)
showCopybooleanNoShow a copy-to-clipboard button under the target (default: true)
onTargetCopy(text: string) => voidNoCalled with the target text after it is copied
loadingbooleanNoShow a skeleton in the target pane and mark it aria-busy
classNamesTranslationWidgetClassNamesNoPer-part class overrides: root, header, pane, textarea, footer, swap, chip
dir"ltr" | "rtl"NoOverride the auto-detected layout direction
classNamestringNoAdditional CSS classes

Any other prop — id, data-*, aria-* — is spread onto the root element, and the ref is forwarded to it.

Editable source

The source pane is read-only by default. Pass onSourceChange to make it editable, or force it read-only with readOnlySource even when a change handler is present:

<TranslationWidget
  sourceLocale="en"
  targetLocale="de"
  sourceText={source}
  onSourceChange={setSource}
  readOnlySource
/>

Controlled target

The target pane is editable when uncontrolled (defaultTargetText) or when you pass both targetText and onTargetChange. A controlled target with no handler renders read-only, so typed text is never silently dropped:

// Read-only: controlled with no way to report edits
<TranslationWidget sourceLocale="en" targetLocale="fr" targetText="Bonjour" />

// Editable
<TranslationWidget
  sourceLocale="en"
  targetLocale="fr"
  targetText={text}
  onTargetChange={setText}
/>

Language pickers

Pass locales to render a Language Switcher on each pane instead of the static language chip. Each picker updates its pane's locale — the textarea's lang and dir follow — so switching the target to Arabic flips that pane to RTL:

<TranslationWidget
  locales={[{ code: "en" }, { code: "fr" }, { code: "ar" }]}
  defaultSourceLocale="en"
  defaultTargetLocale="fr"
  sourceText={source}
  onSourceChange={setSource}
/>

Locale state can be uncontrolled (defaultSourceLocale / defaultTargetLocale) or controlled (sourceLocale + onSourceLocaleChange, same for the target). When locales is omitted, the widget keeps the compact static chips.

Swap

The swap button between the panes exchanges both the locales and the texts — source ↔ target — and fires onSwap. It sits in the header bar between the two language pickers, and its icon spins half a turn on every swap. The button carries an aria-label="Swap languages" and is disabled when any exchanged value is controlled without a change handler, since the swap could not be applied:

<TranslationWidget
  locales={locales}
  sourceLocale={sourceLocale}
  targetLocale={targetLocale}
  onSourceLocaleChange={setSourceLocale}
  onTargetLocaleChange={setTargetLocale}
  sourceText={source}
  onSourceChange={setSource}
  targetText={target}
  onTargetChange={setTarget}
  onSwap={() => analytics.track("languages-swapped")}
/>

Toolbar

Each pane ends in a small toolbar. The source shows a live character count — n / max when you pass maxLength, turning destructive at the limit — and a clear button when the pane is editable. The target has a copy button that confirms with a check and announces "Translation copied to clipboard" to screen readers:

<TranslationWidget
  sourceLocale="en"
  targetLocale="fr"
  sourceText={source}
  onSourceChange={setSource}
  maxLength={500}
  onTargetCopy={(text) => analytics.track("translation-copied", { length: text.length })}
/>

Turn any of it off with showCount={false}, showClear={false} or showCopy={false}.

Loading state

While a translation is in flight, pass loading to replace the target text with a pulsing skeleton. The textarea is marked aria-busy and copying is disabled:

const [loading, setLoading] = useState(false);

async function translate(text: string) {
  setLoading(true);
  setTarget(await api.translate(text, targetLocale));
  setLoading(false);
}

<TranslationWidget loading={loading} /* … */ />;

Customization

Every part takes extra classes through classNames, merged after the defaults with cn, so your classes win:

<TranslationWidget
  classNames={{
    root: "rounded-2xl shadow-lg",
    header: "bg-muted/50",
    textarea: "font-serif text-lg",
    swap: "bg-primary text-primary-foreground",
  }}
/>

RTL support

When the target locale is an RTL language (ar, he, fa, ur, …) or carries an RTL script subtag (az-Arab), the target pane's text flows right-to-left. The pane order never changes — source stays first, target second — and each pane carries its own dir and lang, so a mixed LTR→RTL pair reads correctly on both sides. Pass dir to set the widget's direction explicitly.

<TranslationWidget sourceLocale="en" targetLocale="ar" sourceText="Hello, world!" />

Features

  • Side-by-side panes — source and target in one card, stacked on narrow screens
  • Language pickers — pass locales for a Language Switcher per pane; static chips otherwise
  • Swap button — exchanges both locales and texts, with onSwap and an accessible label
  • Editable target — uncontrolled, or controlled with targetText + onTargetChange (a controlled target without a handler renders read-only so typed text is never silently dropped)
  • RTL-aware — per-pane dir and lang follow each locale; pane order stays fixed
  • Toolbar — character count with maxLength, clear, and copy-to-clipboard
  • Loading state — skeleton target pane with aria-busy
  • Accessible — labelled textareas, lang attributes, visible focus rings, live copy announcement
  • Keyboard support — Tab between panes, type to translate
  • Themeable — shadcn tokens (bg-card, border, muted, ring), so it follows your theme and dark mode
  • Customizable — classNames for every part
  • Motion — spring swap icon and copy confirmation, respecting reduced-motion settings
  • Controlled/uncontrolled — works with or without targetText; same for locales

Design

One card, split into a source pane and a slightly tinted target pane, with both language pickers and the swap button in a shared header bar — the layout people know from translation tools. Colors come from shadcn's semantic tokens, so the widget takes on your theme, radius and dark mode without extra configuration.

Dependencies

This component uses:

  • clsx and tailwind-merge (bundled as dependencies)
  • lucide-react for icons and motion for the swap and copy animations
  • the Language Switcher component (pulled in automatically via registry dependencies)

Peer dependencies:

  • react >= 18
  • react-dom >= 18
  • tailwindcss >= 3

On this page