AI translation UI
Translating with AI and checking it. Source and target side by side with detection and swap, other wordings per sentence with the uncertain ones marked, a glossary of fixed terms and the form of address, and whole documents in several languages. Every view says that a machine translated.
Translate fast, check where it counts
Machine translation is fast, the work lies in checking it. These four screens make that check easy, they mark uncertain sentences, keep fixed terms the same every time and say in every view that a machine translated the text.
Side by side has two panes with the languages above and swap in the middle, the source language is detected with how sure it is, characters are counted and the translation copies with one click. Wordings per sentence aligns source and translation sentence by sentence, pointing at one lights up its partner, and every sentence opens other wordings with a note, uncertain ones stay marked until you choose. Glossary and address keeps fixed terms on the left, always translated the same way or kept as they are, marks every term it applied and switches between formal and informal address. Documents translates whole files into several languages at once, with pages, words and progress per file and passages to check counted.
Build the glossary first, product names and key terms, then every translation starts consistent. Pick the form of address once per language and keep it. Let a person review the marked passages before anything goes live.
Variants
Details
What AI translation brings along, read from the registry and the source.
- Category
- AI writing and creation
- Area
- App
- Variants
- 4
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/ai-translation.tsx
- Brings along
- utilskiticonrevealmotion
- npm packages
- None
- Languages
- English and German, 60 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The AiTranslationProps interface from components/ui/ai-translation.tsx, with the comments from the code.
variantoptionalAiTranslationVariantside-by-sidesentence-alternativesglossarydocument
titleoptionalstringdescriptionoptionalstringlanguagesoptionalTranslationLanguage[]fromoptionalstringSource language code, "auto" detects it
tooptionalstringdetectedoptional{ code: string; confidence: number }The detected source language with the share it is sure of, in percent
sourceoptionalstringside-by-side, glossary: source text
targetoptionalstringside-by-side, glossary: translated text
targetInformaloptionalstringglossary: the text in the informal form
sentencesoptionalTranslationSentence[]sentence-alternatives: the text sentence by sentence
glossaryoptionalGlossaryTerm[]filesoptionalTranslationFile[]document: files and target languages
targetsoptionalstring[]maxCharsoptionalnumberMost characters per text
modeloptionalstringe.g. "Nova Translate"
noteoptionalstringonTranslateoptional(text: string, from: string, to: string) => string | Promise<string>Connect a real translation. Return the text, or a promise of it. Without it the block shows the target text it was given.
Built-in texts
AI translation writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 60 texts
| Key | English | German |
|---|---|---|
| from | English Translate from | German Übersetzen aus |
| to | English Translate into | German Übersetzen in |
| detect | English Detect | German Erkennen |
| detected | English Detected: {lang} | German Erkannt: {lang} |
| detectedSure | English {v} sure | German {v} sicher |
| swap | English Swap languages | German Sprachen tauschen |
| source | English Text to translate | German Text zum Übersetzen |
| target | English Translation | German Übersetzung |
| placeholder | English Type or paste text | German Text eingeben oder einfügen |
| chars | English {n} of {max} characters | German {n} von {max} Zeichen |
| translate | English Translate | German Übersetzen |
| translating | English Translating | German Wird übersetzt |
| copy | English Copy translation | German Übersetzung kopieren |
| copied | English Copied | German Kopiert |
| clear | English Clear text | German Text leeren |
| empty | English The translation appears here. | German Hier erscheint die Übersetzung. |
| machine | English Translated by AI. Check important texts before you publish them. | German Von KI übersetzt. Prüfe wichtige Texte, bevor du sie veröffentlichst. |
| sentenceHint | English Choose a sentence for other wordings | German Wähle einen Satz für andere Formulierungen |
| alternatives | English Other wordings | German Andere Formulierungen |
| original | English In the source | German Im Original |
| use | English Use this wording | German Diese Formulierung nehmen |
| current | English In use | German Verwendet |
| check | English Please check | German Bitte prüfen |
| checkN | English {n} sentences to check | German {n} Sätze zum Prüfen |
| check1 | English 1 sentence to check | German 1 Satz zum Prüfen |
| glossary | English Glossary | German Glossar |
| glossaryHint | English Terms that are always translated the same way, or not at all. | German Begriffe, die immer gleich oder gar nicht übersetzt werden. |
| term | English Term | German Begriff |
| termTarget | English Translation | German Übersetzung |
| keep | English Keep as is | German Nicht übersetzen |
| useRule | English Always as | German Immer als |
| add | English Add term | German Begriff hinzufügen |
| remove | English Remove {term} | German {term} entfernen |
| applied | English {n} terms applied | German {n} Begriffe angewendet |
| applied1 | English 1 term applied | German 1 Begriff angewendet |
| fromGlossary | English From the glossary | German Aus dem Glossar |
| form | English Form of address | German Anrede |
| formal | English Formal | German Sie |
| informal | English Informal | German Du |
| documents | English Documents | German Dokumente |
| targets | English Target languages | German Zielsprachen |
| choose | English Choose files | German Dateien wählen |
| drop | English Word, PDF and slides, layout stays as it is | German Word, PDF und Folien, das Layout bleibt erhalten |
| pages | English {n} pages | German {n} Seiten |
| page1 | English 1 page | German 1 Seite |
| words | English {n} words | German {n} Wörter |
| done | English Done | German Fertig |
| translatingFile | English Translating | German Wird übersetzt |
| queued | English Waiting | German Wartet |
| review | English Ready to check | German Bereit zum Prüfen |
| failed | English Failed | German Fehlgeschlagen |
| flagged | English {n} passages to check | German {n} Stellen zum Prüfen |
| flagged1 | English 1 passage to check | German 1 Stelle zum Prüfen |
| download | English Download {name} | German {name} herunterladen |
| open | English Check | German Prüfen |
| retry | English Try again | German Neu versuchen |
| cancel | English Cancel {name} | German {name} abbrechen |
| removeLang | English Remove {lang} | German {lang} entfernen |
| addLang | English Add language | German Sprache hinzufügen |
| total | English {done} of {total} done | German {done} von {total} fertig |
Accessibility
Read from the source of the component.
- AI translation handles the keys Arrow left, Arrow right and Space itself.
- Sets aria-busy, aria-checked, aria-controls, aria-expanded, aria-label, aria-labelledby and aria-live, so screen readers announce purpose and state.
- Uses the roles group, radio and radiogroup.
- Announces changes through a live region without moving focus.
- Every field has a label, visible or for screen readers.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 4 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 857 lines
'use client';
import { Fragment, useEffect, useId, useRef, useState } from 'react';
import { cn, formatPercent, prefersReducedMotion, useInView, useLocale, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Reveal } from '@/components/ui/reveal';
import { Stagger } from '@/components/ui/motion';
export type AiTranslationVariant = 'side-by-side' | 'sentence-alternatives' | 'glossary' | 'document';
export interface TranslationLanguage {
code: string;
label: string;
}
export interface TranslationAlternative {
id: string;
text: string;
/** What makes it different, e.g. "More formal" */
note?: string;
}
export interface TranslationSentence {
id: string;
source: string;
target: string;
alternatives?: TranslationAlternative[];
/** low: shown as "please check" */
confidence?: 'high' | 'medium' | 'low';
}
export interface GlossaryTerm {
id: string;
source: string;
/** The fixed translation, leave out with rule "keep" */
target?: string;
/** use: always this translation, keep: never translate */
rule: 'use' | 'keep';
}
export interface TranslationFile {
id: string;
name: string;
pages?: number;
words?: number;
status: 'done' | 'translating' | 'queued' | 'review' | 'failed';
/** 0 to 100 while translating */
progress?: number;
/** review: passages to check */
flagged?: number;
error?: string;
}
export interface AiTranslationProps {
variant?: AiTranslationVariant;
title?: string;
description?: string;
languages?: TranslationLanguage[];
/** Source language code, "auto" detects it */
from?: string;
to?: string;
/** The detected source language with the share it is sure of, in percent */
detected?: { code: string; confidence: number };
/** side-by-side, glossary: source text */
source?: string;
/** side-by-side, glossary: translated text */
target?: string;
/** glossary: the text in the informal form */
targetInformal?: string;
/** sentence-alternatives: the text sentence by sentence */
sentences?: TranslationSentence[];
glossary?: GlossaryTerm[];
/** document: files and target languages */
files?: TranslationFile[];
targets?: string[];
/** Most characters per text */
maxChars?: number;
/** e.g. "Nova Translate" */
model?: string;
note?: string;
/**
* Connect a real translation. Return the text, or a promise of it. Without
* it the block shows the target text it was given.
*/
onTranslate?: (text: string, from: string, to: string) => string | Promise<string>;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
const TEXTS = {
en: {
from: 'Translate from',
to: 'Translate into',
detect: 'Detect',
detected: 'Detected: {lang}',
detectedSure: '{v} sure',
swap: 'Swap languages',
source: 'Text to translate',
target: 'Translation',
placeholder: 'Type or paste text',
chars: '{n} of {max} characters',
translate: 'Translate',
translating: 'Translating',
copy: 'Copy translation',
copied: 'Copied',
clear: 'Clear text',
empty: 'The translation appears here.',
machine: 'Translated by AI. Check important texts before you publish them.',
sentenceHint: 'Choose a sentence for other wordings',
alternatives: 'Other wordings',
original: 'In the source',
use: 'Use this wording',
current: 'In use',
check: 'Please check',
checkN: '{n} sentences to check',
check1: '1 sentence to check',
glossary: 'Glossary',
glossaryHint: 'Terms that are always translated the same way, or not at all.',
term: 'Term',
termTarget: 'Translation',
keep: 'Keep as is',
useRule: 'Always as',
add: 'Add term',
remove: 'Remove {term}',
applied: '{n} terms applied',
applied1: '1 term applied',
fromGlossary: 'From the glossary',
form: 'Form of address',
formal: 'Formal',
informal: 'Informal',
documents: 'Documents',
targets: 'Target languages',
choose: 'Choose files',
drop: 'Word, PDF and slides, layout stays as it is',
pages: '{n} pages',
page1: '1 page',
words: '{n} words',
done: 'Done',
translatingFile: 'Translating',
queued: 'Waiting',
review: 'Ready to check',
failed: 'Failed',
flagged: '{n} passages to check',
flagged1: '1 passage to check',
download: 'Download {name}',
open: 'Check',
retry: 'Try again',
cancel: 'Cancel {name}',
removeLang: 'Remove {lang}',
addLang: 'Add language',
total: '{done} of {total} done',
},
de: {
from: 'Übersetzen aus',
to: 'Übersetzen in',
detect: 'Erkennen',
detected: 'Erkannt: {lang}',
detectedSure: '{v} sicher',
swap: 'Sprachen tauschen',
source: 'Text zum Übersetzen',
target: 'Übersetzung',
placeholder: 'Text eingeben oder einfügen',
chars: '{n} von {max} Zeichen',
translate: 'Übersetzen',
translating: 'Wird übersetzt',
copy: 'Übersetzung kopieren',
copied: 'Kopiert',
clear: 'Text leeren',
empty: 'Hier erscheint die Übersetzung.',
machine: 'Von KI übersetzt. Prüfe wichtige Texte, bevor du sie veröffentlichst.',
sentenceHint: 'Wähle einen Satz für andere Formulierungen',
alternatives: 'Andere Formulierungen',
original: 'Im Original',
use: 'Diese Formulierung nehmen',
current: 'Verwendet',
check: 'Bitte prüfen',
checkN: '{n} Sätze zum Prüfen',
check1: '1 Satz zum Prüfen',
glossary: 'Glossar',
glossaryHint: 'Begriffe, die immer gleich oder gar nicht übersetzt werden.',
term: 'Begriff',
termTarget: 'Übersetzung',
keep: 'Nicht übersetzen',
useRule: 'Immer als',
add: 'Begriff hinzufügen',
remove: '{term} entfernen',
applied: '{n} Begriffe angewendet',
applied1: '1 Begriff angewendet',
fromGlossary: 'Aus dem Glossar',
form: 'Anrede',
formal: 'Sie',
informal: 'Du',
documents: 'Dokumente',
targets: 'Zielsprachen',
choose: 'Dateien wählen',
drop: 'Word, PDF und Folien, das Layout bleibt erhalten',
pages: '{n} Seiten',
page1: '1 Seite',
words: '{n} Wörter',
done: 'Fertig',
translatingFile: 'Wird übersetzt',
queued: 'Wartet',
review: 'Bereit zum Prüfen',
failed: 'Fehlgeschlagen',
flagged: '{n} Stellen zum Prüfen',
flagged1: '1 Stelle zum Prüfen',
download: '{name} herunterladen',
open: 'Prüfen',
retry: 'Neu versuchen',
cancel: '{name} abbrechen',
removeLang: '{lang} entfernen',
addLang: 'Sprache hinzufügen',
total: '{done} von {total} fertig',
},
};
type Texts = (typeof TEXTS)['en'];
type P = AiTranslationProps & { t: Texts };
const fill = (s: string, v: Record<string, string | number>) => s.replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
/** State seeded from a prop that starts over when the prop changes (the Studio edits props live) */
function useSynced<T>(value: T) {
const [state, setState] = useState(value);
const key = JSON.stringify(value);
const seen = useRef(key);
if (seen.current !== key) {
seen.current = key;
setState(value);
}
return [state, setState] as const;
}
const CSS =
'@keyframes atr-up{from{opacity:0;transform:translateY(6px)}}@keyframes atr-in{from{opacity:0;filter:blur(3px)}}@keyframes atr-shimmer{from{background-position:200% 0}to{background-position:-200% 0}}' +
'@media (prefers-reduced-motion:no-preference){.atr-up{animation:atr-up .4s var(--ease-kit) var(--atr-d,0ms) both}.atr-in{animation:atr-in .5s var(--ease-kit) var(--atr-d,0ms) both}.atr-shimmer{animation:atr-shimmer 1.6s linear infinite}}';
function Style() {
return (
<style href="kit-ai-translation" precedence="default">
{CSS}
</style>
);
}
/* ------------------------------------------------------------------ */
/* Shared parts */
/* ------------------------------------------------------------------ */
const card = 'rounded-2xl border border-border bg-card shadow-[var(--highlight),var(--shadow-sm)]';
const btn = 'inline-flex h-11 shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-lg px-3.5 text-[13px] font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 sm:h-9';
const primaryBtn = cn(btn, 'bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.18),var(--shadow-sm)] hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--background))]');
const quietBtn = cn(btn, 'border border-border bg-card text-foreground hover:bg-accent');
const iconBtn = 'grid size-11 shrink-0 place-items-center rounded-lg text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40 sm:size-9';
function Stage({ title, description, children, className, wide = true }: { title?: string; description?: string; children: React.ReactNode; className?: string; wide?: boolean }) {
return (
<section className={cn('kit-glow relative overflow-clip bg-surface py-12 sm:py-16 lg:py-20', className)}>
<Style />
<div className="kit-container">
<div className={cn('mx-auto w-full', wide ? 'max-w-[68rem]' : 'max-w-[52rem]')}>
{(title || description) && (
<Reveal className="mb-6 sm:mb-8">
{title && <h2 className="text-balance text-[22px] font-semibold leading-tight tracking-[-0.02em] text-foreground sm:text-[26px]">{title}</h2>}
{description && <p className="mt-2 max-w-[40rem] text-pretty text-[15px] leading-relaxed text-muted-foreground">{description}</p>}
</Reveal>
)}
{children}
</div>
</div>
</section>
);
}
function Machine({ t, model, children }: { t: Texts; model?: string; children?: React.ReactNode }) {
return (
<p className="mt-4 flex items-start gap-2 px-1 text-[12.5px] leading-snug text-muted-foreground">
<Icon name="languages" size={14} className="mt-px shrink-0" />
<span>
{children ?? t.machine}
{model && <span className="text-subtle-foreground"> · {model}</span>}
</span>
</p>
);
}
/** A native select that looks like the kit, with a chevron */
function LangSelect({ id, label, value, onChange, options, auto, t }: { id: string; label: string; value: string; onChange: (v: string) => void; options: TranslationLanguage[]; auto?: boolean; t: Texts }) {
return (
<span className="relative inline-flex min-w-0">
<label htmlFor={id} className="sr-only">
{label}
</label>
<select id={id} value={value} onChange={(e) => onChange(e.target.value)} className="h-11 min-w-0 max-w-full cursor-pointer appearance-none truncate rounded-lg bg-transparent py-0 pl-2.5 pr-8 text-[14px] font-medium text-foreground transition-colors hover:bg-accent sm:h-9">
{auto && <option value="auto">{t.detect}</option>}
{options.map((o) => (
<option key={o.code} value={o.code}>
{o.label}
</option>
))}
</select>
<Icon name="chevron-down" size={15} className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
</span>
);
}
function useCopy() {
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!copied) return;
const id = window.setTimeout(() => setCopied(false), 1800);
return () => window.clearTimeout(id);
}, [copied]);
return [copied, (text: string) => (navigator.clipboard?.writeText(text).catch(() => undefined), setCopied(true))] as const;
}
const langLabel = (languages: TranslationLanguage[], code?: string) => languages.find((l) => l.code === code)?.label ?? code ?? '';
/* ------------------------------------------------------------------ */
/* side-by-side */
/* ------------------------------------------------------------------ */
function SideBySide({ t, title, description, languages = [], from: from0 = 'auto', to: to0 = 'en', detected, source = '', target = '', maxChars = 5000, model, note, onTranslate, className }: P) {
const locale = useLocale();
const uid = useId();
const [from, setFrom] = useState(from0);
const [to, setTo] = useState(to0);
const [text, setText] = useSynced(source);
const [out, setOut] = useSynced(target);
const [busy, setBusy] = useState(false);
const [stale, setStale] = useState(false);
const [spin, setSpin] = useState(0);
const [copied, copy] = useCopy();
const fromCode = from === 'auto' ? detected?.code ?? '' : from;
const run = async () => {
if (!text.trim()) return;
setBusy(true);
const next = onTranslate ? await onTranslate(text, fromCode, to) : text === source ? target : out;
window.setTimeout(() => (setOut(next), setBusy(false), setStale(false)), prefersReducedMotion() ? 0 : 700);
};
const swap = () => {
setSpin((n) => n + 1);
setFrom(to);
setTo(fromCode || to);
setText(out);
setOut(text);
};
return (
<Stage title={title} description={description} className={className}>
<Reveal className={cn(card, 'overflow-hidden')}>
{/* Language bar with swap in the middle */}
<div className="grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center gap-1 border-b border-border px-2 py-1.5">
<div className="flex min-w-0 items-center gap-2">
<LangSelect id={`${uid}-f`} label={t.from} value={from} onChange={(v) => (setFrom(v), setStale(true))} options={languages} auto t={t} />
</div>
<button type="button" aria-label={t.swap} title={t.swap} disabled={busy} onClick={swap} className={cn(iconBtn, 'border border-border bg-card')}>
<Icon name="arrows-left-right" size={16} className="transition-transform duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none" style={{ transform: `rotate(${spin * 180}deg)` }} />
</button>
<div className="flex min-w-0 justify-end md:justify-start md:pl-3">
<LangSelect id={`${uid}-t`} label={t.to} value={to} onChange={(v) => (setTo(v), setStale(true))} options={languages.filter((l) => l.code !== fromCode)} t={t} />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2">
<div className="relative flex min-h-[15rem] flex-col border-border transition-shadow focus-within:shadow-[inset_0_0_0_2px_color-mix(in_oklab,var(--primary)_55%,transparent)] max-md:border-b md:border-r">
{from === 'auto' && detected && (
<p className="atr-up mx-4 mt-3 inline-flex w-fit items-center gap-1.5 rounded-md bg-[color-mix(in_oklab,var(--primary)_10%,transparent)] px-2 py-1 text-[12px] font-medium text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]">
<Icon name="sparkle" size={12} />
{fill(t.detected, { lang: langLabel(languages, detected.code) })}
<span className="font-normal text-muted-foreground">· {fill(t.detectedSure, { v: formatPercent(detected.confidence, locale) })}</span>
</p>
)}
<label htmlFor={`${uid}-s`} className="sr-only">
{t.source}
</label>
<textarea
id={`${uid}-s`}
value={text}
maxLength={maxChars}
onChange={(e) => (setText(e.target.value), setStale(true))}
placeholder={t.placeholder}
rows={7}
className="block w-full flex-1 resize-none bg-transparent px-4 py-3 text-[16px] leading-relaxed text-foreground placeholder:text-subtle-foreground focus-visible:outline-none sm:text-[17px]"
/>
<div className="flex items-center gap-2 px-2 pb-2">
<button type="button" aria-label={t.clear} title={t.clear} disabled={!text} onClick={() => (setText(''), setStale(true))} className={iconBtn}>
<Icon name="x" size={16} />
</button>
<span className="min-w-0 flex-1 truncate text-[12px] tabular-nums text-subtle-foreground">{fill(t.chars, { n: text.length.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US'), max: maxChars.toLocaleString(locale === 'de' ? 'de-DE' : 'en-US') })}</span>
<button type="button" disabled={busy || !text.trim()} onClick={run} className={cn(stale ? primaryBtn : quietBtn)}>
<Icon name={busy ? 'loader' : 'languages'} size={15} className={busy ? 'animate-spin motion-reduce:animate-none' : undefined} />
{busy ? t.translating : t.translate}
</button>
</div>
</div>
<div className="relative flex min-h-[15rem] flex-col bg-[color-mix(in_oklab,var(--foreground)_2.5%,var(--card))]">
<p className="sr-only" id={`${uid}-o`}>
{t.target}
</p>
<div aria-labelledby={`${uid}-o`} aria-live="polite" aria-busy={busy} className={cn('flex-1 px-4 py-3 text-[16px] leading-relaxed sm:text-[17px]', from === 'auto' && detected && 'md:pt-[3.25rem]', stale && !busy ? 'text-muted-foreground' : 'text-foreground')}>
{busy ? (
<span aria-hidden className="block space-y-2.5 pt-1.5">
{[92, 78, 86, 54].map((w, i) => (
<span key={i} className="atr-shimmer block h-3.5 rounded-md bg-[linear-gradient(90deg,var(--track),color-mix(in_oklab,var(--primary)_20%,var(--track)),var(--track))] bg-[length:200%_100%]" style={{ width: `${w}%` }} />
))}
</span>
) : out ? (
<span key={out} className="atr-in block whitespace-pre-wrap">
{out}
</span>
) : (
<span className="text-subtle-foreground">{t.empty}</span>
)}
</div>
<div className="flex items-center justify-end gap-1 px-2 pb-2">
<button type="button" disabled={!out || busy} onClick={() => copy(out)} className={cn(btn, 'text-muted-foreground hover:bg-accent hover:text-foreground')} aria-live="polite">
<Icon name={copied ? 'check' : 'copy'} size={15} className={copied ? 'text-success-foreground' : undefined} />
{copied ? t.copied : t.copy}
</button>
</div>
</div>
</div>
</Reveal>
<Machine t={t} model={model}>
{note}
</Machine>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* sentence-alternatives */
/* ------------------------------------------------------------------ */
function SentenceAlternatives({ t, title, description, languages = [], from = 'de', to = 'en', sentences: initial = [], model, note, className }: P) {
const uid = useId();
const [sentences, setSentences] = useSynced(initial);
const [hover, setHover] = useState<string | null>(null);
const [open, setOpen] = useState<string | null>(initial.find((s) => s.confidence === 'low')?.id ?? null);
const [picked, setPicked] = useState<Record<string, string>>({});
const toCheck = sentences.filter((s) => s.confidence === 'low' && !picked[s.id]).length;
const choose = (s: TranslationSentence, alt: TranslationAlternative) => {
setSentences((list) => list.map((x) => (x.id === s.id ? { ...x, target: alt.text, alternatives: [{ id: `${s.id}-was`, text: s.target }, ...(x.alternatives ?? []).filter((a) => a.id !== alt.id)] } : x)));
setPicked((p) => ({ ...p, [s.id]: alt.id }));
};
return (
<Stage title={title} description={description} className={className}>
<Reveal className={cn(card, 'overflow-hidden')}>
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-3">
<p className="flex items-center gap-2 text-[13px] font-medium text-foreground">
{langLabel(languages, from)}
<Icon name="arrow-right" size={14} className="text-muted-foreground" />
{langLabel(languages, to)}
</p>
<p className={cn('text-[12.5px]', toCheck ? 'text-warning-foreground' : 'text-success-foreground')} aria-live="polite">
{toCheck ? (toCheck === 1 ? t.check1 : fill(t.checkN, { n: toCheck })) : <span className="inline-flex items-center gap-1.5"><Icon name="check-circle" size={14} />{t.current}</span>}
</p>
</div>
<div aria-hidden className="hidden grid-cols-2 border-b border-border text-[12px] font-medium text-muted-foreground md:grid">
<span className="border-r border-border px-4 py-2">{t.original}</span>
<span className="px-4 py-2">{t.target}</span>
</div>
<Stagger as="ol" step={60}>
{sentences.map((s) => {
const on = open === s.id;
const lit = hover === s.id || on;
const low = s.confidence === 'low' && !picked[s.id];
return (
<li key={s.id} className="grid grid-cols-1 border-b border-border last:border-b-0 md:grid-cols-2" onPointerEnter={() => setHover(s.id)} onPointerLeave={() => setHover(null)}>
<p className={cn('border-border px-4 pb-1 pt-3 text-[14.5px] leading-relaxed transition-colors md:border-r md:pb-3', lit ? 'bg-[color-mix(in_oklab,var(--primary)_7%,transparent)] text-foreground' : 'text-muted-foreground')}>
<span className="sr-only">{t.original}: </span>
{s.source}
</p>
<div className={cn('px-2 pb-2 pt-0 transition-colors md:pt-2', lit && 'bg-[color-mix(in_oklab,var(--primary)_7%,transparent)]')}>
<button
type="button"
aria-expanded={on}
aria-controls={`${uid}-${s.id}`}
onClick={() => setOpen(on ? null : s.id)}
onFocus={() => setHover(s.id)}
onBlur={() => setHover(null)}
className="group flex min-h-11 w-full items-start gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-[color-mix(in_oklab,var(--foreground)_5%,transparent)]"
>
<span className={cn('min-w-0 flex-1 text-[14.5px] leading-relaxed text-foreground', low && 'underline decoration-[color-mix(in_oklab,var(--warning)_85%,transparent)] decoration-dotted decoration-2 underline-offset-[5px]')}>
<span key={s.target} className="atr-in">
{s.target}
</span>
{low && <span className="ml-2 inline-flex h-5 items-center rounded-[5px] bg-[color-mix(in_oklab,var(--warning)_16%,transparent)] px-1.5 align-[0.1em] text-[11px] font-medium text-warning-foreground no-underline">{t.check}</span>}
</span>
{s.alternatives?.length ? <Icon name="chevron-down" size={15} className={cn('mt-1 shrink-0 text-subtle-foreground transition-transform duration-300 motion-reduce:transition-none', on && 'rotate-180 text-foreground')} /> : null}
</button>
{on && s.alternatives?.length ? (
<div id={`${uid}-${s.id}`} role="group" aria-label={t.alternatives} className="atr-up mt-1 space-y-1.5 px-2 pb-1">
<p className="text-[12px] font-medium text-muted-foreground">{t.alternatives}</p>
{s.alternatives.map((a) => (
<button key={a.id} type="button" onClick={() => choose(s, a)} className="flex w-full items-start gap-2.5 rounded-lg border border-border bg-card px-3 py-2.5 text-left transition-colors hover:border-border-strong hover:bg-accent">
<Icon name="repeat" size={14} className="mt-1 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1">
<span className="block text-[14px] leading-relaxed text-foreground">{a.text}</span>
{a.note && <span className="mt-0.5 block text-[12px] text-muted-foreground">{a.note}</span>}
</span>
<span className="sr-only">{t.use}</span>
</button>
))}
</div>
) : null}
</div>
</li>
);
})}
</Stagger>
</Reveal>
<Machine t={t} model={model}>
{note}
</Machine>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* glossary */
/* ------------------------------------------------------------------ */
const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
/** The text with every glossary term in it marked */
function WithTerms({ text, terms, t }: { text: string; terms: GlossaryTerm[]; t: Texts }) {
const words = terms.map((g) => (g.rule === 'keep' ? g.source : g.target ?? '')).filter(Boolean);
if (!words.length) return <>{text}</>;
const re = new RegExp(`(${words.sort((a, b) => b.length - a.length).map(escape).join('|')})`, 'g');
return (
<>
{text.split(re).map((part, i) =>
words.includes(part) ? (
<mark key={i} title={t.fromGlossary} className="rounded-[3px] bg-[color-mix(in_oklab,var(--primary)_16%,transparent)] text-foreground shadow-[inset_0_-1.5px_0_color-mix(in_oklab,var(--primary)_60%,transparent)]">
{part}
</mark>
) : (
<Fragment key={i}>{part}</Fragment>
),
)}
</>
);
}
function Glossary({ t, title, description, languages = [], from = 'de', to = 'en', source = '', target = '', targetInformal, glossary: initial = [], model, note, className }: P) {
const uid = useId();
const [terms, setTerms] = useSynced(initial);
const [form, setForm] = useState<'formal' | 'informal'>('formal');
const [src, setSrc] = useState('');
const [dst, setDst] = useState('');
const [rule, setRule] = useState<'use' | 'keep'>('use');
const text = form === 'informal' && targetInformal ? targetInformal : target;
const applied = terms.filter((g) => text.includes(g.rule === 'keep' ? g.source : g.target ?? '\u0000')).length;
const add = () => {
if (!src.trim() || (rule === 'use' && !dst.trim())) return;
setTerms((list) => [...list, { id: `g-${list.length}-${src}`, source: src.trim(), target: rule === 'use' ? dst.trim() : undefined, rule }]);
setSrc('');
setDst('');
};
const forms = [
{ id: 'formal' as const, label: t.formal },
{ id: 'informal' as const, label: t.informal },
];
return (
<Stage title={title} description={description} className={className}>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[22rem_minmax(0,1fr)] lg:items-start lg:gap-5">
<Reveal className={cn(card, 'p-4')}>
<p className="flex items-center gap-1.5 text-[13px] font-medium text-foreground">
<Icon name="book" size={15} />
{t.glossary}
</p>
<p className="mt-1 text-[12.5px] leading-snug text-muted-foreground">{t.glossaryHint}</p>
<ul className="mt-3 divide-y divide-border rounded-xl border border-border">
{terms.map((g) => (
<li key={g.id} className="atr-up flex items-center gap-2 py-1.5 pl-3 pr-1">
<span className="min-w-0 flex-1">
<span className="block truncate text-[13.5px] font-medium text-foreground">{g.source}</span>
<span className="flex items-center gap-1 text-[12px] text-muted-foreground">
{g.rule === 'keep' ? (
<>
<Icon name="lock" size={11} />
{t.keep}
</>
) : (
<>
<Icon name="arrow-right" size={11} />
<span className="truncate">{g.target}</span>
</>
)}
</span>
</span>
<button type="button" aria-label={fill(t.remove, { term: g.source })} onClick={() => setTerms((list) => list.filter((x) => x.id !== g.id))} className={iconBtn}>
<Icon name="x" size={14} />
</button>
</li>
))}
</ul>
<form
className="mt-3 space-y-2"
onSubmit={(e) => {
e.preventDefault();
add();
}}
>
<div className="grid grid-cols-2 gap-2">
<label className="min-w-0">
<span className="mb-1 block text-[12px] text-muted-foreground">{t.term}</span>
<input value={src} onChange={(e) => setSrc(e.target.value)} className="h-11 w-full min-w-0 rounded-lg border border-border bg-background px-3 text-[14px] text-foreground focus:border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] sm:h-9" />
</label>
<label className="min-w-0">
<span className="mb-1 block text-[12px] text-muted-foreground">{t.termTarget}</span>
<input value={rule === 'keep' ? src : dst} disabled={rule === 'keep'} onChange={(e) => setDst(e.target.value)} className="h-11 w-full min-w-0 rounded-lg border border-border bg-background px-3 text-[14px] text-foreground focus:border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] disabled:opacity-60 sm:h-9" />
</label>
</div>
<div className="flex flex-wrap items-center gap-2">
<label className="inline-flex min-h-11 cursor-pointer items-center gap-2 text-[12.5px] font-medium text-foreground sm:min-h-9">
<input type="checkbox" checked={rule === 'keep'} onChange={(e) => setRule(e.target.checked ? 'keep' : 'use')} className="size-4 accent-[var(--primary)]" />
{t.keep}
</label>
<span className="flex-1" />
<button type="submit" disabled={!src.trim() || (rule === 'use' && !dst.trim())} className={quietBtn}>
<Icon name="plus" size={15} />
{t.add}
</button>
</div>
</form>
</Reveal>
<Reveal delay={100} className={cn(card, 'overflow-hidden')}>
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-2.5">
<p className="flex items-center gap-2 text-[13px] font-medium text-foreground">
{langLabel(languages, from)}
<Icon name="arrow-right" size={14} className="text-muted-foreground" />
{langLabel(languages, to)}
</p>
{targetInformal && (
<div role="radiogroup" aria-label={t.form} className="flex gap-1 rounded-lg bg-[color-mix(in_oklab,var(--foreground)_6%,transparent)] p-1">
{forms.map((f) => (
<button key={f.id} type="button" role="radio" data-f={f.id} aria-checked={form === f.id} tabIndex={form === f.id ? 0 : -1} onClick={() => setForm(f.id)} onKeyDown={(e) => ['ArrowLeft', 'ArrowRight'].includes(e.key) && (e.preventDefault(), setForm(f.id === 'formal' ? 'informal' : 'formal'), (e.currentTarget.parentElement?.querySelector(`[data-f="${f.id === 'formal' ? 'informal' : 'formal'}"]`) as HTMLElement | null)?.focus())} className={cn('inline-flex h-11 min-w-11 items-center justify-center rounded-md px-3 text-[12.5px] font-medium transition-colors sm:h-8', form === f.id ? 'bg-card text-foreground shadow-[var(--highlight),var(--shadow-sm)]' : 'text-muted-foreground hover:text-foreground')}>
{f.label}
</button>
))}
</div>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2">
<div className="border-border px-4 py-4 max-md:border-b md:border-r">
<p className="mb-2 text-[12px] font-medium text-muted-foreground">{t.original}</p>
<p className="whitespace-pre-wrap text-[14.5px] leading-relaxed text-muted-foreground">{source}</p>
</div>
<div className="px-4 py-4">
<p className="mb-2 text-[12px] font-medium text-muted-foreground" id={`${uid}-o`}>
{t.target}
</p>
<p key={`${form}-${terms.length}`} aria-labelledby={`${uid}-o`} className="atr-in whitespace-pre-wrap text-[14.5px] leading-relaxed text-foreground">
<WithTerms text={text} terms={terms} t={t} />
</p>
</div>
</div>
<p className="flex items-center gap-1.5 border-t border-border px-4 py-2.5 text-[12.5px] text-muted-foreground" aria-live="polite">
<span aria-hidden className="size-2.5 rounded-[3px] bg-[color-mix(in_oklab,var(--primary)_35%,transparent)]" />
{applied === 1 ? t.applied1 : fill(t.applied, { n: applied })}
</p>
</Reveal>
</div>
<Machine t={t} model={model}>
{note}
</Machine>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/* document */
/* ------------------------------------------------------------------ */
const FILE_TONE: Record<TranslationFile['status'], { text: string; dot: string }> = {
done: { text: 'text-success-foreground', dot: 'bg-success' },
translating: { text: 'text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]', dot: 'bg-primary' },
queued: { text: 'text-muted-foreground', dot: 'bg-muted-foreground' },
review: { text: 'text-warning-foreground', dot: 'bg-warning' },
failed: { text: 'text-destructive', dot: 'bg-destructive' },
};
function DocumentTranslation({ t, title, description, languages = [], files: initial = [], targets: targets0 = [], model, note, className }: P) {
const locale = useLocale();
const uid = useId();
const nf = new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US');
const [files, setFiles] = useSynced(initial);
const [targets, setTargets] = useState(targets0);
const [ref, inView] = useInView<HTMLDivElement>();
const input = useRef<HTMLInputElement>(null);
const running = files.some((f) => f.status === 'translating');
const queued = files.some((f) => f.status === 'queued');
useEffect(() => {
if (!inView || prefersReducedMotion() || (!running && !queued)) return;
const id = window.setInterval(() => {
setFiles((cur) => {
let next = cur.map((f) => (f.status === 'translating' ? { ...f, progress: Math.min(100, (f.progress ?? 0) + 1) } : f));
const done = next.findIndex((f) => f.status === 'translating' && (f.progress ?? 0) >= 100);
if (done >= 0) next = next.map((f, i) => (i === done ? { ...f, status: f.flagged ? ('review' as const) : ('done' as const), progress: undefined } : f));
if (!next.some((f) => f.status === 'translating')) {
const q = next.findIndex((f) => f.status === 'queued');
if (q >= 0) next = next.map((f, i) => (i === q ? { ...f, status: 'translating' as const, progress: 0 } : f));
}
return next;
});
}, 140);
return () => window.clearInterval(id);
}, [inView, running, queued, setFiles]);
const statusLabel = (f: TranslationFile) => (f.status === 'translating' ? t.translatingFile : t[f.status]);
const finished = files.filter((f) => f.status === 'done' || f.status === 'review').length;
const free = languages.filter((l) => !targets.includes(l.code));
return (
<Stage title={title} description={description} className={className} wide={false}>
<div ref={ref}>
<Reveal className={cn(card, 'p-4 sm:p-5')}>
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-[13px] font-medium text-foreground">{t.targets}</p>
<p className="text-[12.5px] tabular-nums text-muted-foreground" aria-live="polite">
{fill(t.total, { done: finished, total: files.length })}
</p>
</div>
<ul className="mt-2.5 flex flex-wrap items-center gap-1.5">
{targets.map((code) => (
<li key={code} className="atr-up inline-flex h-11 items-center gap-1 rounded-lg border border-border bg-surface pl-3 pr-1 text-[13px] font-medium text-foreground sm:h-9">
{langLabel(languages, code)}
<button type="button" aria-label={fill(t.removeLang, { lang: langLabel(languages, code) })} disabled={targets.length < 2} onClick={() => setTargets((x) => x.filter((c) => c !== code))} className="grid size-11 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 sm:size-7">
<Icon name="x" size={13} />
</button>
</li>
))}
{free.length > 0 && (
<li className="relative">
<label htmlFor={`${uid}-add`} className="sr-only">
{t.addLang}
</label>
<select id={`${uid}-add`} value="" onChange={(e) => e.target.value && setTargets((x) => [...x, e.target.value])} className="h-11 cursor-pointer appearance-none rounded-lg border border-dashed border-border-strong bg-transparent pl-8 pr-3 text-[13px] font-medium text-muted-foreground hover:bg-accent hover:text-foreground sm:h-9">
<option value="">{t.addLang}</option>
{free.map((l) => (
<option key={l.code} value={l.code}>
{l.label}
</option>
))}
</select>
<Icon name="plus" size={14} className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
</li>
)}
</ul>
<div className="mt-4 flex flex-wrap items-center gap-3 rounded-xl border border-dashed border-border-strong p-3">
<span aria-hidden className="grid size-10 place-items-center rounded-xl bg-surface-2 text-muted-foreground">
<Icon name="cloud-upload" size={18} />
</span>
<p className="min-w-0 flex-1 text-[13px] leading-snug text-muted-foreground">{t.drop}</p>
<input ref={input} type="file" multiple accept=".doc,.docx,.pdf,.ppt,.pptx,.odt" className="sr-only" tabIndex={-1} onChange={(e) => {
const list = Array.from(e.target.files ?? []);
setFiles((cur) => [...cur, ...list.map((f, i) => ({ id: `new-${cur.length + i}`, name: f.name, status: 'queued' as const, words: Math.round(f.size / 7) }))]);
e.target.value = '';
}} />
<button type="button" onClick={() => input.current?.click()} className={cn(quietBtn, 'max-sm:w-full')}>
<Icon name="paperclip" size={15} />
{t.choose}
</button>
</div>
<ul aria-label={t.documents} className="mt-4 divide-y divide-border">
{files.map((f) => {
const tone = FILE_TONE[f.status];
return (
<li key={f.id} className={cn('flex items-center gap-x-3 gap-y-2 py-3 sm:flex-nowrap', f.status === 'translating' || f.status === 'queued' ? 'flex-nowrap' : 'flex-wrap')}>
<span aria-hidden className="grid size-10 shrink-0 place-items-center rounded-lg bg-[color-mix(in_oklab,var(--primary)_10%,var(--card))] text-[color-mix(in_oklab,var(--primary)_75%,var(--foreground))]">
<Icon name={(f.name.endsWith('.pptx') ? 'presentation' : 'file') as IconName} size={17} />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-[13.5px] font-medium text-foreground">{f.name}</p>
<p className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[12px] text-muted-foreground">
<span className={cn('inline-flex items-center gap-1.5 font-medium', tone.text)}>
<span aria-hidden className={cn('size-1.5 rounded-full', tone.dot, f.status === 'translating' && 'animate-kit-pulse motion-reduce:animate-none')} />
{statusLabel(f)}
{f.status === 'translating' && <span className="tabular-nums text-muted-foreground">{formatPercent(Math.round(f.progress ?? 0), locale)}</span>}
</span>
{f.pages ? <span className="tabular-nums">· {f.pages === 1 ? t.page1 : fill(t.pages, { n: f.pages })}</span> : null}
{f.words ? <span className="tabular-nums max-sm:hidden">· {fill(t.words, { n: nf.format(f.words) })}</span> : null}
</p>
{f.status === 'translating' && (
<span aria-hidden className="mt-2 block h-1 overflow-hidden rounded-full bg-track">
<span className="block h-full origin-left rounded-full bg-primary transition-transform duration-200" style={{ transform: `scaleX(${(f.progress ?? 0) / 100})` }} />
</span>
)}
{f.status === 'review' && f.flagged ? <p className="mt-1 text-[12px] text-warning-foreground">{f.flagged === 1 ? t.flagged1 : fill(t.flagged, { n: f.flagged })}</p> : null}
{f.status === 'failed' && f.error ? <p className="mt-1 text-[12px] leading-snug text-destructive">{f.error}</p> : null}
</div>
<div className={cn('flex shrink-0 items-center gap-1.5', f.status !== 'translating' && f.status !== 'queued' && 'max-sm:w-full max-sm:pl-[3.25rem]')}>
{f.status === 'review' && (
<button type="button" className={cn(quietBtn, 'max-sm:flex-1')}>
<Icon name="eye" size={15} />
{t.open}
</button>
)}
{(f.status === 'done' || f.status === 'review') && (
<button type="button" aria-label={fill(t.download, { name: f.name })} className={cn(f.status === 'done' ? primaryBtn : quietBtn, 'max-sm:flex-1')}>
<Icon name="download" size={15} />
<span className="tabular-nums">{targets.map((c) => c.toUpperCase()).join(' · ')}</span>
</button>
)}
{f.status === 'failed' && (
<button type="button" onClick={() => setFiles((cur) => cur.map((x) => (x.id === f.id ? { ...x, status: 'queued', error: undefined } : x)))} className={cn(quietBtn, 'max-sm:flex-1')}>
<Icon name="refresh" size={15} />
{t.retry}
</button>
)}
{(f.status === 'translating' || f.status === 'queued') && (
<button type="button" aria-label={fill(t.cancel, { name: f.name })} onClick={() => setFiles((cur) => cur.filter((x) => x.id !== f.id))} className={iconBtn}>
<Icon name="x" size={15} />
</button>
)}
</div>
</li>
);
})}
</ul>
</Reveal>
</div>
<Machine t={t} model={model}>
{note}
</Machine>
</Stage>
);
}
/* ------------------------------------------------------------------ */
/**
* AI translation: source and target side by side with language detection
* and swap, every sentence with other wordings to choose and the ones to
* check marked, a glossary of fixed terms with the form of address, and whole
* documents in several languages with progress and review. Always says that a
* machine translated and that a person should check.
*/
export function AiTranslation({ variant = 'side-by-side', labels, ...props }: AiTranslationProps) {
const t = useTexts(TEXTS, labels);
const p: P = { ...props, t };
switch (variant) {
case 'sentence-alternatives':
return <SentenceAlternatives {...p} />;
case 'glossary':
return <Glossary {...p} />;
case 'document':
return <DocumentTranslation {...p} />;
default:
return <SideBySide {...p} />;
}
}Frequent questions
How do I install AI translation?
With aiw add @ai-website/ai-translation. The command puts components/ui/ai-translation.tsx into your project, the code is yours from then on. It brings along icon, reveal and motion. It needs no further npm packages.
Which variants does AI translation have?
4 variants, Side by side, Wordings per sentence, Glossary and address and Documents. You pick one with the variant prop, for example variant="sentence-alternatives".
Can I translate the texts of AI translation?
Yes. AI translation carries 60 texts of its own in English and German, such as “Translate from”, “Translate into” and “Detect”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with AI translation?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find AI translation with aiw_components and install it with aiw_add @ai-website/ai-translation. Name the variant in your prompt, for example variant="side-by-side", and the assistant writes your content straight into the props.