# Translate

Translate text between 24 language pairs, swap the direction with one click to carry the result back into the input, and copy or clear as you go. It uses your browser's built-in translator when available, or a downloadable model otherwise. Nothing downloads until you ask.

**Install**

```bash
npx shadcn@latest add @localmode/ui/blocks/writing-tools/translate
```

**Full block (all files):** https://localmode.ai/r/ui/blocks/writing-tools/translate.json

```tsx
'use client';

/**
 * @file translate.tsx
 * @description Translate block — 24 directed offline Opus-MT pairs (English-centric, every model id verified) with swap-carries-output, per-panel char counts, copy, cancel, and truthful badging (Chrome Translator API ⇄ Opus-MT fallback).
 */
import { useEffect, useState } from 'react';
import {
  useTranslate,
  useProviderFallback,
  toAppError,
  providerName,
  type ResolvedModel,
} from '@localmode/react';
import type { TranslationModel } from '@localmode/core';

import { LanguagePairSelector } from '@/components/language-pair-selector';
import { ProviderBadge } from '@/components/provider-badge';
import { ChromeAIDownloadGate } from '@/components/chrome-ai-download-gate';
import { CopyButton } from '@/components/copy-button';
import { ErrorAlert } from '@/components/error-alert';
import { cn } from '@/lib/utils';

interface Language {
  code: string;
  name: string;
  flag: string;
}

const LANGUAGES: Language[] = [
  { code: 'en', name: 'English', flag: '🇬🇧' },
  { code: 'de', name: 'German', flag: '🇩🇪' },
  { code: 'fr', name: 'French', flag: '🇫🇷' },
  { code: 'es', name: 'Spanish', flag: '🇪🇸' },
  { code: 'it', name: 'Italian', flag: '🇮🇹' },
  { code: 'nl', name: 'Dutch', flag: '🇳🇱' },
  { code: 'ru', name: 'Russian', flag: '🇷🇺' },
  { code: 'zh', name: 'Chinese', flag: '🇨🇳' },
  { code: 'ar', name: 'Arabic', flag: '🇸🇦' },
  { code: 'hi', name: 'Hindi', flag: '🇮🇳' },
  { code: 'fi', name: 'Finnish', flag: '🇫🇮' },
  { code: 'uk', name: 'Ukrainian', flag: '🇺🇦' },
  { code: 'sv', name: 'Swedish', flag: '🇸🇪' },
];

const NON_ENGLISH_CODES = LANGUAGES.filter((l) => l.code !== 'en').map((l) => l.code);

const DIRECTED_PAIR_COUNT = NON_ENGLISH_CODES.length * 2;

const OPUS_MT_SIZE = '~80 MB per pair';

function opusMtModelId(source: string, target: string): string {
  return `Xenova/opus-mt-${source}-${target}`;
}

function languageByCode(code: string): Language {
  return LANGUAGES.find((l) => l.code === code) ?? { code, name: code, flag: '' };
}

const TRANSLATE_SAMPLE = 'The weather is nice today and I want to go for a walk.';

const FIRST_NON_EN = LANGUAGES.find((l) => l.code !== 'en')!.code;

export function TranslateBlock() {
  const [source, setSource] = useState('en');
  const [target, setTarget] = useState('de');
  const [input, setInput] = useState('');
  const [resolved, setResolved] = useState<ResolvedModel<TranslationModel> | null>(null);
  const {
    resolveTranslator,
    chromeAvailability,
    refreshChromeAvailability,
    requestChromeDownload,
    chromeDownloadProgress,
    downloadingCapability,
    error: providerError,
  } = useProviderFallback({
    loadChromeAI: () => import('@localmode/chrome-ai'),
    loadTransformers: () => import('@localmode/transformers'),
  });

  const { data, error, isLoading, execute, cancel, reset } = useTranslate({
    model: resolved?.model as TranslationModel,
  });

  const translateAvailability = chromeAvailability.translate ?? 'unsupported';
  useEffect(() => {
    void refreshChromeAvailability('translate', { source, target });
  }, [source, target, refreshChromeAvailability]);

  useEffect(() => {
    let alive = true;
    setResolved(null);
    void resolveTranslator({
      source,
      target,
      fallbackModelId: opusMtModelId(source, target),
    }).then((r) => {
      if (alive) setResolved(r);
    });
    return () => {
      alive = false;
    };
  }, [source, target, resolveTranslator, translateAvailability]);

  const output = data?.translation ?? '';
  const ready = !!resolved;
  const appErr = toAppError(error);
  const modelId = data?.response.modelId ?? resolved?.modelId ?? null;

  const selectSource = (code: string) => {
    reset();
    if (code === 'en') {
      setSource('en');
      if (target === 'en') setTarget(FIRST_NON_EN);
    } else {
      setSource(code);
      setTarget('en');
    }
  };
  const selectTarget = (code: string) => {
    reset();
    if (code === 'en') {
      setTarget('en');
      if (source === 'en') setSource(FIRST_NON_EN);
    } else {
      setTarget(code);
      setSource('en');
    }
  };

  const swap = () => {
    const carried = output;
    reset();
    setSource(target);
    setTarget(source);
    if (carried) setInput(carried);
  };

  const translate = () => {
    if (!input.trim() || !ready || isLoading) return;
    void execute({ text: input, sourceLanguage: source, targetLanguage: target });
  };

  const targetName = languageByCode(target).name;

  return (
    <div className="mx-auto flex max-w-4xl flex-col gap-4 p-4">
      <p className="text-xs text-muted-foreground">
        Translate - 24 offline Opus-MT pairs. Models load only behind an explicit action.
      </p>

      <ChromeAIDownloadGate
        availability={translateAvailability}
        label={`Chrome Translator (${source}\u2192${target})`}
        size="one language pack"
        isDownloading={downloadingCapability === 'translate'}
        progress={chromeDownloadProgress?.progress}
        error={providerError?.message ?? null}
        fallbackLabel="Transformers.js (Opus-MT)"
        onDownload={() => {
          void requestChromeDownload('translate', { source, target });
        }}
      />

      <div className="flex flex-wrap items-center justify-between gap-2">
        <span data-provider={resolved?.provider ?? 'resolving'}>
          <ProviderBadge
            providerName={resolved ? providerName(resolved.provider) : null}
            tier={resolved?.tier ?? 'download'}
            modelId={modelId}
          />
        </span>
        <span data-model-id={modelId ?? ''} className="sr-only">
          {modelId ?? ''}
        </span>
        <div className="flex flex-wrap items-center gap-1.5 text-[10px]">
          {['Works offline', 'Runs locally', `${DIRECTED_PAIR_COUNT} pairs`, `Opus-MT · ${OPUS_MT_SIZE}`].map(
            (b) => (
              <span key={b} className="rounded-full border border-border px-2 py-0.5 text-muted-foreground">
                {b}
              </span>
            ),
          )}
        </div>
      </div>

      {}
      <div className="flex items-center gap-2">
        <LanguagePairSelector
          languages={LANGUAGES}
          sourceCode={source}
          targetCode={target}
          onSelectSource={selectSource}
          onSelectTarget={selectTarget}
          onSwap={swap}
        />
      </div>

      <div className="grid gap-3 sm:grid-cols-2">
        {}
        <div className="flex flex-col gap-1.5">
          <div className="flex items-center justify-between text-xs text-muted-foreground">
            <span>{languageByCode(source).name}</span>
            <span>{input.length} chars</span>
          </div>
          <textarea
            aria-label="Text to translate"
            dir="auto"
            value={input}
            onChange={(e) => setInput(e.target.value)}
            disabled={isLoading}
            rows={6}
            placeholder="Enter text to translate…"
            className="w-full resize-y rounded-md border border-border bg-background p-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
          />
          <button
            type="button"
            onClick={() => setInput(TRANSLATE_SAMPLE)}
            disabled={isLoading}
            className="self-start rounded text-xs text-muted-foreground underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
          >
            Load sample
          </button>
        </div>

        {}
        <div className="flex flex-col gap-1.5">
          <div className="flex items-center justify-between text-xs text-muted-foreground">
            <span>{targetName}</span>
            {output && <span>{output.length} chars</span>}
          </div>
          <div
            data-provider={resolved?.provider ?? ''}
            role="region"
            aria-label="Translation"
            aria-live="polite"
            dir="auto"
            className="min-h-[8.5rem] whitespace-pre-wrap rounded-md border border-border bg-muted/30 p-2 text-sm"
          >
            {isLoading ? (
              <span className="flex flex-col gap-2" aria-label="Translating…">
                {[0, 1, 2].map((i) => (
                  <span key={i} className="h-3 animate-pulse rounded bg-muted" style={{ width: `${90 - i * 15}%` }} />
                ))}
              </span>
            ) : output ? (
              output
            ) : (
              <span className="text-muted-foreground">Translation will appear here…</span>
            )}
          </div>
          {output && !isLoading && (
            <span className="self-start">
              <CopyButton value={output} />
            </span>
          )}
        </div>
      </div>

      <div className="flex items-center gap-2">
        <button
          type="button"
          data-running={isLoading}
          onClick={isLoading ? cancel : translate}
          disabled={!ready || (!input.trim() && !isLoading)}
          className={cn(
            'inline-flex h-8 items-center rounded-md px-3 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50',
            isLoading
              ? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
              : 'bg-primary text-primary-foreground hover:bg-primary/90',
          )}
        >
          {isLoading ? 'Stop' : ready ? `Translate to ${targetName}` : 'Preparing…'}
        </button>
      </div>

      {appErr && (
        <span>
          <ErrorAlert message={appErr.message} onRetry={translate} onDismiss={reset} />
        </span>
      )}
    </div>
  );
}
```
