Einmalcode
Getrennte Felder auf einem echten Eingabefeld: Einfügen, SMS-Autofill und Rücktaste arbeiten wie erwartet.
Eine von 6 Components in Spezielle Eingabefelder
Details
Was Einmalcode mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Spezielle Eingabefelder
- Bereich
- Elements
- Variants
- 2
- Version
- 1.0.0 · seit 1.0.0
- Datei
- components/ui/otp-input.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 2 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle OtpInputProps aus components/ui/otp-input.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
lengthoptionalnumbergroupsoptionalnumber[]Split into groups, e.g. [3, 3] shows 123 · 456
valueoptionalstringdefaultValueoptionalstringonValueChangeoptional(value: string) => voidonCompleteoptional(value: string) => voidCalled once all slots are filled
modeoptional'numeric' | 'alphanumeric'numeric (default) or alphanumeric codes
labeloptionalReact.ReactNodehintoptionalReact.ReactNodeerroroptionalReact.ReactNodesuccessoptionalbooleanMarks the code as accepted
disabledoptionalbooleannameoptionalstringsizeoptional'md' | 'lg'autoFocusoptionalboolean
Eingebaute Texte
Diese Texte schreibt Einmalcode selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 2 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| label | Englisch One-time code | Deutsch Einmalcode |
| digits | Englisch digits | Deutsch 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
'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.