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.
Installation
npx shadcn@latest add @babelize/translation-widgetUsage
"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
| Prop | Type | Required | Description |
|---|---|---|---|
locales | Locale[] | No | Locales for the per-pane language pickers; omit to show static chips |
sourceLocale | string | No | BCP 47 code of the source locale (e.g. "en"); provide to control it |
defaultSourceLocale | string | No | Initial source locale when uncontrolled (default: locales[0], else "en") |
onSourceLocaleChange | (code: string) => void | No | Callback when the source locale changes |
targetLocale | string | No | BCP 47 code of the target locale (e.g. "fr", "ar"); provide to control it |
defaultTargetLocale | string | No | Initial target locale when uncontrolled (default: locales[1], else "en") |
onTargetLocaleChange | (code: string) => void | No | Callback when the target locale changes |
sourceText | string | No | Source text — pass this to control the source pane |
defaultSourceText | string | No | Initial source text when uncontrolled |
onSourceChange | (text: string) => void | No | Callback when the source text changes; makes the pane editable |
targetText | string | No | Target text — pass this to control the target pane (read-only without onTargetChange) |
defaultTargetText | string | No | Initial target text when uncontrolled |
onTargetChange | (text: string) => void | No | Callback when the target text changes |
onSwap | () => void | No | Called after the swap button exchanges locales and texts |
sourcePlaceholder | string | No | Placeholder for the source pane |
targetPlaceholder | string | No | Placeholder for the target pane |
sourceLabel | string | No | Accessible label for the source pane (default: "Source text") |
targetLabel | string | No | Accessible label for the target pane (default: "Target text") |
readOnlySource | boolean | No | Keep the source pane read-only even with onSourceChange |
showCount | boolean | No | Show the source character count (default: true) |
maxLength | number | No | Character limit for both panes; the count shows n / max |
showClear | boolean | No | Show a clear button under an editable source (default: true) |
showCopy | boolean | No | Show a copy-to-clipboard button under the target (default: true) |
onTargetCopy | (text: string) => void | No | Called with the target text after it is copied |
loading | boolean | No | Show a skeleton in the target pane and mark it aria-busy |
classNames | TranslationWidgetClassNames | No | Per-part class overrides: root, header, pane, textarea, footer, swap, chip |
dir | "ltr" | "rtl" | No | Override the auto-detected layout direction |
className | string | No | Additional 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
localesfor a Language Switcher per pane; static chips otherwise - Swap button — exchanges both locales and texts, with
onSwapand 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
dirandlangfollow 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,
langattributes, 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 —
classNamesfor 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:
clsxandtailwind-merge(bundled as dependencies)lucide-reactfor icons andmotionfor the swap and copy animations- the Language Switcher component (pulled in automatically via registry dependencies)
Peer dependencies:
react>= 18react-dom>= 18tailwindcss>= 3
Phone Input
International phone number input with country selector, flag emojis, and dial code search. Supports 50 countries with built-in formatting and validation.
TypeScript Interfaces
Core TypeScript interfaces exported by Babelize Elements components: props, locale types and formatter options for typing your own i18n React components.