# Text Classifier Block

> Custom-label zero-shot classification with MobileBERT MNLI over a user-editable label set (add trimmed + case-insensitively deduped, remove, live count) with an email/message textarea and sample loader. Results render a top-result hero (winning label + confidence) above a ranked score list of all candidate labels. Guards empty input and an empty label set; the model downloads only behind Run. No model bytes download until an explicit in-block action.

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-insights/text-classifier).

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

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/localmode/ui/blocks/text-insights/text-classifier.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/react`, `@localmode/transformers`, `lucide-react`
- Registry items: `https://localmode.ai/r/ui/lib/utils.json`, `https://localmode.ai/r/ui/results/editable-label-set.json`, `https://localmode.ai/r/ui/results/top-result-card.json`, `https://localmode.ai/r/ui/results/scored-result-bar-list.json`, `https://localmode.ai/r/ui/local-first/model-loading-panel.json`, `https://localmode.ai/r/ui/local-first/cache-badge.json`, `https://localmode.ai/r/ui/results/error-alert.json`, `https://localmode.ai/r/ui/results/mode-error-boundary.json`

## Files

### `src/app/blocks/text-insights/text-classifier/text-classifier.tsx`

```tsx
'use client';

/**
 * @file text-classifier.tsx
 * @description Text Classifier block — MobileBERT MNLI custom-label zero-shot routing with an editable label set, a top-result hero over ranked candidate scores, and a message/email sample loader; model download gated behind Run.
 */
import { useState } from 'react';
import { Play, Sparkles, Square } from 'lucide-react';
import {
  useClassifyZeroShot,
  useModelLoad,
  toAppError,
  type UseModelLoadReturn,
} from '@localmode/react';
import { classifyZeroShot, type ZeroShotClassificationModel } from '@localmode/core';
import { transformers, isModelCached } from '@localmode/transformers';

import { EditableLabelSet } from '@/components/editable-label-set';
import { TopResultCard } from '@/components/top-result-card';
import { ScoredResultBarList } from '@/components/scored-result-bar-list';
import { ModelLoadingPanel } from '@/components/model-loading-panel';
import { CacheBadge } from '@/components/cache-badge';
import { ErrorAlert } from '@/components/error-alert';
import { ModeErrorBoundary } from '@/components/mode-error-boundary';

const ZEROSHOT_MODEL_ID = 'Xenova/mobilebert-uncased-mnli';
const ZEROSHOT_MODEL_NAME = 'MobileBERT Zero-Shot';
const ZEROSHOT_MODEL_SIZE = '27 MB';

const DEFAULT_LABELS = ['Support', 'Sales', 'Billing', 'Spam', 'General Inquiry'];

const SAMPLE_EMAILS = [
  'I cannot log into my account. I have tried resetting my password multiple times but keep getting an error.',
  'We are interested in your enterprise plan. Can we schedule a demo for our team of 50 people?',
  'I was charged twice for my subscription this month. Please process a refund immediately.',
  'CONGRATULATIONS! You have won a $1000 gift card! Click here to claim your prize now!!!',
  'When will the new version be released? We are excited about the upcoming features.',
];

export function TextClassifierBlock() {
  return (
    <div className="mx-auto flex max-w-4xl flex-col gap-4 p-4">
      <p className="text-xs text-muted-foreground">
        Text Classifier: MobileBERT MNLI zero-shot. Model loads only behind an explicit action.
      </p>
      <ModeErrorBoundary>
        <ClassifyInner />
      </ModeErrorBoundary>
    </div>
  );
}

function ClassifyInner() {
  const load = useModelLoad<ZeroShotClassificationModel>({
    key: `text-insights-zeroshot:${ZEROSHOT_MODEL_ID}`,
    create: (onProgress) =>
      transformers.zeroShot(ZEROSHOT_MODEL_ID, {
        onProgress: (p) => onProgress(p as Parameters<typeof onProgress>[0]),
      }),
    warmup: (model) =>
      classifyZeroShot({ model, text: 'ready', candidateLabels: ['yes', 'no'] }),
    isCached: () => isModelCached(ZEROSHOT_MODEL_ID),
  });

  const model = load.model;
  if (!model) return <p className="text-sm text-muted-foreground">Preparing…</p>;
  return <ClassifySurface load={load} model={model} />;
}

function ClassifySurface({
  load,
  model,
}: {
  load: UseModelLoadReturn<ZeroShotClassificationModel>;
  model: ZeroShotClassificationModel;
}) {
  const [input, setInput] = useState('');
  const [labels, setLabels] = useState<string[]>(DEFAULT_LABELS);

  const { data, isLoading, error, execute, cancel, reset } = useClassifyZeroShot({ model });
  const appErr = toAppError(error) ?? (load.error ? toAppError(load.error) : null);

  const canRun = input.trim().length > 0 && labels.length > 0;

  const addLabel = (label: string) => {
    const trimmed = label.trim();
    if (!trimmed) return;
    setLabels((prev) => (prev.some((l) => l.toLowerCase() === trimmed.toLowerCase()) ? prev : [...prev, trimmed]));
  };

  const removeLabel = (_: string, index: number) => {
    setLabels((prev) => prev.filter((_, i) => i !== index));
  };

  const run = async () => {
    if (!canRun || isLoading) return;
    try {
      await load.load();
    } catch {
      return;
    }
    await execute({ text: input, candidateLabels: labels });
  };

  const loadSample = () => {
    setInput(SAMPLE_EMAILS[Math.floor(Math.random() * SAMPLE_EMAILS.length)]);
  };

  const ranked = data ? data.labels.map((label, i) => ({ label, score: data.scores[i] })) : [];
  const top = ranked[0];

  return (
    <div className="flex flex-col gap-4">
      <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
        <span>
          {ZEROSHOT_MODEL_NAME} · {ZEROSHOT_MODEL_SIZE}
        </span>
        {load.cached === true && (
          <span>
            <CacheBadge cached label="model cached" />
          </span>
        )}
      </div>

      {}
      <div>
        <div className="mb-1.5 flex items-center justify-between">
          <span className="text-xs font-medium text-muted-foreground">Categories</span>
          <span className="text-xs text-muted-foreground">
            {labels.length} {labels.length === 1 ? 'label' : 'labels'}
          </span>
        </div>
        <div>
          <EditableLabelSet
            labels={labels}
            onAdd={addLabel}
            onRemove={removeLabel}
            placeholder="Add a category…"
          />
        </div>
        {labels.length === 0 && (
          <p className="mt-1 text-xs text-amber-600 dark:text-amber-500">
            Add at least one category to classify.
          </p>
        )}
      </div>

      {}
      <div>
        <textarea
          aria-label="Email or message to route"
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="Paste an email or message to route…"
          rows={5}
          className="w-full resize-y rounded-md border border-border bg-background p-2.5 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
        />
        <div className="mt-1 flex justify-end">
          <button
            type="button"
            onClick={loadSample}
            className="inline-flex h-7 items-center gap-1 rounded-md border border-border px-2 text-xs font-medium hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
          >
            <Sparkles className="h-3 w-3" aria-hidden /> Load sample
          </button>
        </div>
      </div>

      {}
      <div className="flex items-center gap-2">
        <button
          type="button"
          onClick={() => void run()}
          disabled={!canRun || isLoading}
          className="inline-flex h-8 items-center gap-1.5 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"
        >
          <Play className="h-3.5 w-3.5" aria-hidden /> Classify
        </button>
        {isLoading && (
          <button
            type="button"
            onClick={cancel}
            className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-3 text-sm font-medium hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
          >
            <Square className="h-3.5 w-3.5" aria-hidden /> Stop
          </button>
        )}
      </div>

      {}
      {load.status === 'loading' && (
        <div>
          <ModelLoadingPanel
            name={ZEROSHOT_MODEL_NAME}
            size={ZEROSHOT_MODEL_SIZE}
            progress={load.progressValue}
            cached={load.cached === true}
          />
        </div>
      )}

      {appErr && (
        <span>
          <ErrorAlert message={appErr.message} onRetry={() => void run()} onDismiss={reset} />
        </span>
      )}

      {}
      {(isLoading || ranked.length > 0) && (
        <div className="flex flex-col gap-3">
          {top && (
            <div
              role="status"
              aria-label="Top routing result"
              data-label={top.label}
              data-score={top.score.toFixed(4)}
            >
              <TopResultCard label={top.label} score={top.score} title="Routed to" />
            </div>
          )}
          <div>
            <ScoredResultBarList results={ranked} isLoading={isLoading && ranked.length === 0} />
          </div>
        </div>
      )}
    </div>
  );
}
```
