Zum Inhalt springen
UI Kit
Accent

Einmalcode

Getrennte Felder auf einem echten Eingabefeld: Einfügen, SMS-Autofill und Rücktaste arbeiten wie erwartet.

Eine von 6 Components in Spezielle Eingabefelder

2 Variants

Variants

Bestätigen

Sechs Ziffern in zwei Gruppen, in einer Anmeldekarte. Jede Ziffer springt in ihr Feld, ein falscher Code schüttelt, der richtige schickt eine grüne Welle.

Zustände

Leer, falsch und angenommen, dazu eine kurze PIN.

Details

Was Einmalcode mitbringt, aus Registry und Quellcode gelesen.

Bereich
Elements
Variants
2
Version
1.0.0 · seit 1.0.0
Datei
components/ui/otp-input.tsx
Bringt mit
utilskitinputicon
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 2 eigene Texte
Studio
Im Code anpassen

Props

Die Schnittstelle OtpInputProps aus components/ui/otp-input.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • lengthoptional
    number
  • groupsoptional
    number[]

    Split into groups, e.g. [3, 3] shows 123 · 456

  • valueoptional
    string
  • defaultValueoptional
    string
  • onValueChangeoptional
    (value: string) => void
  • onCompleteoptional
    (value: string) => void

    Called once all slots are filled

  • modeoptional
    'numeric' | 'alphanumeric'

    numeric (default) or alphanumeric codes

  • labeloptional
    React.ReactNode
  • hintoptional
    React.ReactNode
  • erroroptional
    React.ReactNode
  • successoptional
    boolean

    Marks the code as accepted

  • disabledoptional
    boolean
  • nameoptional
    string
  • sizeoptional
    'md' | 'lg'
  • autoFocusoptional
    boolean

Eingebaute Texte

Diese Texte schreibt Einmalcode selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 2 Texte anzeigen
labelEnglisch One-time codeDeutsch Einmalcode
digitsEnglisch digitsDeutsch Ziffern

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-describedby, aria-invalid, aria-label und aria-live, damit Screenreader Zweck und Zustand ansagen.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Jedes Feld hat ein Label, sichtbar oder für Screenreader.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 201 Zeilen
components/ui/otp-input.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useTexts } from '@/lib/kit';
import { FieldError, FieldHint } from '@/components/ui/input';
import { Icon } from '@/components/ui/icon';

const TEXTS = {
  en: { label: 'One-time code', digits: 'digits' },
  de: { label: 'Einmalcode', digits: 'Ziffern' },
};

export interface OtpInputProps {
  length?: number;
  /** Split into groups, e.g. [3, 3] shows 123 · 456 */
  groups?: number[];
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  /** Called once all slots are filled */
  onComplete?: (value: string) => void;
  /** numeric (default) or alphanumeric codes */
  mode?: 'numeric' | 'alphanumeric';
  label?: React.ReactNode;
  hint?: React.ReactNode;
  error?: React.ReactNode;
  /** Marks the code as accepted */
  success?: boolean;
  disabled?: boolean;
  name?: string;
  size?: 'md' | 'lg';
  autoFocus?: boolean;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const EASE = 'cubic-bezier(0.2,0.7,0.2,1)';
const moves = () => !prefersReducedMotion() && typeof Element !== 'undefined' && typeof Element.prototype.animate === 'function';

/** Slot motion: a pop for each new character, one shake for a wrong code, a green wave for an accepted one */
function useSlotMotion(row: React.RefObject<HTMLDivElement | null>, value: string, error: boolean, success: boolean) {
  const slots = () => Array.from(row.current?.querySelectorAll<HTMLElement>('[data-slot]') ?? []);
  const wave = () => {
    if (!moves()) return;
    slots().forEach((el, i) =>
      el.animate(
        [
          { transform: 'translateY(0) scale(1)' },
          { transform: 'translateY(-5px) scale(1.06)', boxShadow: '0 0 0 3px color-mix(in oklab, var(--success) 22%, transparent)', offset: 0.45 },
          { transform: 'translateY(0) scale(1)' },
        ],
        { duration: 520, delay: i * 55, easing: EASE },
      ),
    );
  };
  const prev = useRef({ value, error, success });
  useEffect(() => {
    const p = prev.current;
    prev.current = { value, error, success };
    if (!moves()) return;
    if (value.length > p.value.length) {
      const list = slots();
      for (let i = p.value.length; i < value.length; i++)
        list[i]?.animate(
          [
            { transform: 'scale(0.82)', opacity: 0.4 },
            { transform: 'scale(1.08)', opacity: 1, offset: 0.6 },
            { transform: 'scale(1)', opacity: 1 },
          ],
          { duration: 300, delay: (i - p.value.length) * 35, easing: EASE },
        );
    }
    if (error && !p.error) row.current?.animate([{ transform: 'translateX(0)' }, { transform: 'translateX(-7px)' }, { transform: 'translateX(6px)' }, { transform: 'translateX(-4px)' }, { transform: 'translateX(2px)' }, { transform: 'translateX(0)' }], { duration: 440, easing: 'ease-out' });
    if (success && !p.success) wave();
  }, [value, error, success]);
  // Accepted from the start: the wave runs once when the code scrolls into view
  useEffect(() => {
    const el = row.current;
    if (!success || !el || typeof IntersectionObserver === 'undefined') return;
    const io = new IntersectionObserver(([e]) => {
      if (!e.isIntersecting) return;
      io.disconnect();
      wave();
    });
    io.observe(el);
    return () => io.disconnect();
  }, []);
}

/**
 * A one-time code in separate slots, built on one real input. Paste, autofill
 * from SMS (autocomplete="one-time-code"), backspace and arrow keys all work natively.
 *   <OtpInput length={6} groups={[3, 3]} onComplete={verify} />
 */
export function OtpInput({ length = 6, groups, value: valueProp, defaultValue, onValueChange, onComplete, mode = 'numeric', label, hint, error, success, disabled, name, size = 'lg', autoFocus, labels, className }: OtpInputProps) {
  const t = useTexts(TEXTS, labels);
  const id = useId();
  const inputRef = useRef<HTMLInputElement>(null);
  const [inner, setInner] = useState((defaultValue ?? '').slice(0, length));
  const value = valueProp ?? inner;
  const [focused, setFocused] = useState(false);
  const [caret, setCaret] = useState(value.length);
  const pattern = mode === 'numeric' ? /[^0-9]/g : /[^0-9a-zA-Z]/g;
  const active = Math.min(caret, length - 1);
  const rowRef = useRef<HTMLDivElement>(null);
  useSlotMotion(rowRef, value, Boolean(error), Boolean(success));

  const sizes = groups ?? [length];
  const slots: number[][] = [];
  let k = 0;
  for (const g of sizes) {
    slots.push(Array.from({ length: g }, () => k++));
  }

  const syncCaret = () => requestAnimationFrame(() => setCaret(inputRef.current?.selectionStart ?? value.length));
  const describedBy = error ? `${id}-err` : hint ? `${id}-hint` : undefined;

  return (
    <div className={cn('grid w-full justify-items-center gap-3', className)}>
      <label htmlFor={`${id}-input`} className={cn('text-sm font-medium text-foreground', !label && 'sr-only')}>
        {label ?? t.label}
      </label>
      {/* Slots keep their size where there is room and shrink together in a narrow column, instead of spilling out of it */}
      <div className="relative max-w-full" onClick={() => inputRef.current?.focus()}>
        <div ref={rowRef} aria-hidden className="flex items-center gap-1.5 sm:gap-2">
          {slots.map((group, gi) => (
            <div key={gi} className="flex min-w-0 items-center gap-1.5 sm:gap-2">
              {gi > 0 && <span className="h-0.5 w-2.5 shrink-0 rounded-full bg-border-strong sm:mx-0.5 sm:w-3" />}
              {group.map((i) => {
                const char = value[i];
                const isActive = focused && i === active && (value.length < length || caret < length);
                return (
                  <span
                    key={i}
                    data-slot
                    className={cn(
                      'relative grid place-items-center rounded-lg border bg-background font-semibold tabular-nums text-foreground shadow-[var(--shadow-sm)] transition-[border-color,box-shadow,background-color] duration-300 ease-[var(--ease-kit)]',
                      'min-w-0',
                      // A fixed width that may shrink: the row then has its real width even inside a shrink-to-fit column
                      size === 'lg' ? 'h-12 w-9 shrink text-xl sm:h-14 sm:w-11 sm:text-2xl' : 'h-11 w-9 shrink text-lg sm:w-10',
                      'border-input',
                      isActive && 'border-[color-mix(in_oklab,var(--primary)_60%,var(--border-strong))] shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_16%,transparent),var(--shadow-sm)]',
                      error && 'border-[color-mix(in_oklab,var(--destructive)_70%,var(--border))]',
                      success && 'border-[color-mix(in_oklab,var(--success)_60%,var(--border))] bg-[color-mix(in_oklab,var(--success)_6%,var(--background))]',
                      disabled && 'bg-muted text-muted-foreground shadow-none',
                    )}
                  >
                    {char ?? ''}
                    {isActive && !char && <span className="absolute h-6 w-px animate-[kit-pulse_1s_steps(2)_infinite] bg-foreground motion-reduce:animate-none" />}
                  </span>
                );
              })}
            </div>
          ))}
        </div>
        <input
          ref={inputRef}
          id={`${id}-input`}
          name={name}
          value={value}
          disabled={disabled}
          autoFocus={autoFocus}
          inputMode={mode === 'numeric' ? 'numeric' : 'text'}
          autoComplete="one-time-code"
          autoCapitalize="characters"
          spellCheck={false}
          maxLength={length}
          pattern={mode === 'numeric' ? `[0-9]{${length}}` : undefined}
          aria-invalid={error ? true : undefined}
          aria-describedby={describedBy}
          aria-label={typeof label === 'string' ? `${label}, ${length} ${t.digits}` : undefined}
          onFocus={() => {
            setFocused(true);
            syncCaret();
          }}
          onBlur={() => setFocused(false)}
          onKeyUp={syncCaret}
          onSelect={syncCaret}
          onChange={(e) => {
            const next = e.target.value.replace(pattern, '').slice(0, length).toUpperCase();
            setInner(next);
            onValueChange?.(next);
            syncCaret();
            if (next.length === length) onComplete?.(next);
          }}
          className="absolute inset-0 w-full cursor-text bg-transparent text-transparent caret-transparent opacity-0 outline-none selection:bg-transparent disabled:cursor-not-allowed"
        />
      </div>
      {error ? (
        <FieldError id={`${id}-err`}>{error}</FieldError>
      ) : success ? (
        <p className="flex items-center gap-1.5 text-[13px] font-medium text-success-foreground" aria-live="polite">
          <Icon name="check-circle" size={15} />
          {hint}
        </p>
      ) : hint ? (
        <FieldHint id={`${id}-hint`}>{hint}</FieldHint>
      ) : null}
    </div>
  );
}

Häufige Fragen

Wie installiere ich Einmalcode?

Mit aiw add @ai-website/otp-input. Der Befehl legt die Datei components/ui/otp-input.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind input und icon. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Einmalcode?

2 Varianten, Bestätigen und Zustände.

Kann ich die Texte von Einmalcode übersetzen?

Ja. Einmalcode bringt 2 eigene Texte auf Deutsch und Englisch mit, etwa „Einmalcode“ und „Ziffern“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Einmalcode einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Einmalcode mit aiw_components und installieren sie mit aiw_add @ai-website/otp-input.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert