# Language Detector Block

> Debounced (400 ms) automatic language detection across 110 languages (up to five candidates with name + ISO code + confidence) that runs once typed text reaches 10 characters with no button press, plus a two-text cosine-similarity comparison via the on-device MediaPipe text embedder. Fully on-device; models download only when typing crosses the auto-detect threshold or Compare is pressed. Regrouped from ui/blocks/vision-lab (Language tab) into a new standalone text category (roadmap D2 — NOT merged into text-insights).

A shadcn/ui block from the [@localmode](https://localmode.ai/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/localmode/ui/blocks/text/language-detector).

- Page, with a live preview: https://shadcn.exchange/r/localmode/ui/blocks/text/language-detector
- Screenshot: https://shadcn.exchange/shot/localmode/ui/blocks/text/language-detector/light.png
- Registry item: https://localmode.ai/r/ui/blocks/text/language-detector.json
- Categories: blocks, text
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/localmode/ui/blocks/text/language-detector.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@localmode/core`, `@localmode/mediapipe`, `@localmode/react`
- Registry items: `https://localmode.ai/r/ui/local-first/capability-gate.json`, `https://localmode.ai/r/ui/results/cosine-similarity-meter.json`, `https://localmode.ai/r/ui/results/scored-result-bar-list.json`

## Files

### `src/app/blocks/text/language-detector/language-detector.tsx`

```tsx
'use client';

/**
 * @file language-detector.tsx
 * @description Text — Language Detector: debounced 110-language auto-detection (up to 5 candidates) plus an on-device text-embedder cosine-similarity comparison, fully on-device via MediaPipe.
 */

import { useEffect, useRef, useState } from 'react';
import { useDetectLanguage } from '@localmode/react';
import {
  cosineSimilarity,
  embed,
  getLanguageName,
  type EmbeddingModel,
  type LanguageDetectionModel,
} from '@localmode/core';
import { mediapipe } from '@localmode/mediapipe';

import { CapabilityGate } from '@/components/capability-gate';
import { CosineSimilarityMeter } from '@/components/cosine-similarity-meter';
import { ScoredResultBarList } from '@/components/scored-result-bar-list';

const MIN_DETECT_LENGTH = 10;
const DETECT_DEBOUNCE_MS = 400;

let languageModel: LanguageDetectionModel | null = null;
const getLanguageDetector = () => (languageModel ??= mediapipe.languageDetector());
let embedderModel: EmbeddingModel | null = null;
const getTextEmbedder = () => (embedderModel ??= mediapipe.textEmbedder());

export function LanguageDetectorBlock() {
  return (
    <div className="flex flex-col gap-3 p-4">
      <p className="sr-only">
        Language detector
      </p>
      <CapabilityGate requires="wasm">
        <div className="flex flex-col gap-3">
          <DetectionSection />
          <SimilaritySection />
        </div>
      </CapabilityGate>
    </div>
  );
}


function DetectionSection() {
  const [text, setText] = useState('');
  const detect = useDetectLanguage({ model: getLanguageDetector(), maxResults: 5 });

  const executeRef = useRef(detect.execute);
  const resetRef = useRef(detect.reset);
  useEffect(() => {
    executeRef.current = detect.execute;
    resetRef.current = detect.reset;
  });

  useEffect(() => {
    if (text.trim().length < MIN_DETECT_LENGTH) {
      resetRef.current();
      return;
    }
    const id = window.setTimeout(() => void executeRef.current(text), DETECT_DEBOUNCE_MS);
    return () => window.clearTimeout(id);
  }, [text]);

  const candidates = (detect.data?.languages ?? []).map((l) => ({
    code: l.languageCode,
    name: getLanguageName(l.languageCode),
    confidence: l.confidence,
  }));

  const status = detect.isLoading
    ? 'detecting'
    : detect.error
      ? 'error'
      : candidates.length > 0
        ? 'done'
        : 'idle';

  return (
    <section className="grid items-start gap-3 rounded-lg border border-border p-4 lg:grid-cols-[1fr_1fr]">
      <div className="flex flex-col gap-2">
        <p className="text-sm font-medium">Language detection</p>
        <textarea
          aria-label="Text to detect language"
          value={text}
          onChange={(e) => setText(e.target.value)}
          rows={4}
          placeholder="Type or paste text in any language… e.g. Bonjour le monde"
          className="w-full resize-y rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
        />
        <p className="text-xs text-muted-foreground">
          Detection runs automatically after {MIN_DETECT_LENGTH} characters. Supports 110
          languages; the model downloads on first detection.
        </p>
        {detect.error && (
          <p className="text-xs text-destructive">
            {detect.error.message}
          </p>
        )}
      </div>

      <div className="flex flex-col gap-2">
        <p className="text-sm font-medium">Detected languages</p>
        <p
          data-status={status}
          className="sr-only"
        >
          {status}
        </p>
        <span
          data-code={candidates[0]?.code ?? ''}
          className="sr-only"
        >
          {candidates[0]?.code ?? ''}
        </span>
        <div>
          <ScoredResultBarList
            results={candidates.map((c) => ({
              label: `${c.name} (${c.code})`,
              score: c.confidence,
            }))}
            isLoading={detect.isLoading}
            emptyState={`Type at least ${MIN_DETECT_LENGTH} characters to detect the language.`}
          />
        </div>
      </div>
    </section>
  );
}


function SimilaritySection() {
  const [textA, setTextA] = useState('');
  const [textB, setTextB] = useState('');
  const [similarity, setSimilarity] = useState<number | null>(null);
  const [isComparing, setIsComparing] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const compare = async () => {
    if (!textA.trim() || !textB.trim() || isComparing) return;
    setIsComparing(true);
    setError(null);
    try {
      const model = getTextEmbedder();
      const [a, b] = await Promise.all([
        embed({ model, value: textA }),
        embed({ model, value: textB }),
      ]);
      setSimilarity(cosineSimilarity(a.embedding, b.embedding));
    } catch (err) {
      setError(err instanceof Error ? err.message : String(err));
    } finally {
      setIsComparing(false);
    }
  };

  return (
    <section className="flex flex-col gap-3 rounded-lg border border-border p-4">
      <div>
        <p className="text-sm font-medium">Text similarity</p>
        <p className="text-xs text-muted-foreground">
          Both texts are embedded with the MediaPipe text embedder (Universal Sentence Encoder);
          the meter shows their cosine similarity.
        </p>
      </div>

      <div className="grid gap-2 lg:grid-cols-2">
        <textarea
          aria-label="First text to compare"
          value={textA}
          onChange={(e) => setTextA(e.target.value)}
          rows={3}
          placeholder="Enter the first sentence…"
          className="w-full resize-y rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
        />
        <textarea
          aria-label="Second text to compare"
          value={textB}
          onChange={(e) => setTextB(e.target.value)}
          rows={3}
          placeholder="Enter the second sentence…"
          className="w-full resize-y rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
        />
      </div>

      <div className="flex flex-wrap items-center gap-3">
        <button
          type="button"
          onClick={() => void compare()}
          disabled={!textA.trim() || !textB.trim() || isComparing}
          className="inline-flex h-8 items-center rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
        >
          {isComparing ? 'Comparing…' : 'Compare similarity'}
        </button>
        {(!textA.trim() || !textB.trim()) && !isComparing && (
          <span className="text-xs text-muted-foreground">
            Enter text in both boxes to compare.
          </span>
        )}
        {error && (
          <p className="text-xs text-destructive">
            {error}
          </p>
        )}
      </div>

      {similarity != null && (
        <div data-score={similarity.toFixed(4)}>
          <CosineSimilarityMeter similarity={similarity} caption="text A ↔ text B" />
        </div>
      )}
    </section>
  );
}
```
