shadimage
knock-codes
componentcode-fieldsource: ok · hit

Code field

Displays controlled demo-code entry, normalizing pasted message artifacts. Does not verify codes, unlock content, store values, or make network requests. Contract: value and onChange are required; the consumer owns all state.

npx shadcn add "https://knock.codes/r/react/code-field.json"
free

Where it goes

stats

Visual style

terminal-mono

Interaction

animated-counter

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Files1 · source cached 10/14/2026, 12:08:25 PM

registry/components/code-field.tsx
/**
 * MIT License — Copyright (c) 2026 Knock contributors.
 * Displays controlled demo-code entry, normalizing pasted message artifacts.
 * Does not verify codes, unlock content, store values, or make network requests.
 * Contract: value and onChange are required; the consumer owns all state.
 * @version 0.2.1
 * @minimalExample <CodeField value={value} onChange={setValue} />
 * @fullExample <CodeField value={value} onChange={setValue} mode="digits" length={8} masked status="error" error="Check your invitation code." label="Your code" description="Paste from your invitation." autoFocus name="preview-code" theme="light" className="max-w-sm" />
 * @lifecycle gate
 * @whenToUse You are building your own gate layout and need only the entry field, with codes pasted out of an email tidied on the way in.
 * @notFor A complete gate screen. Use the Quick gate or Client preview gate block, which already compose this field with a heading, a submit action and lifecycle notices.
 * @pitfall `length` is a display and autofill hint, not validation. The field never rejects input and never reports whether a code is correct.
 * @pitfall `status="success"` only styles the field. Revealing content is your own `unlocked` state, decided after your own server check.
 * @pitfall `autoFocus` moves focus on mount. Leave it off when the field sits below other content or inside a long page.
 * @a11y Native controls, visible focus, localized labels; lifecycle messages use polite announcements. No modal dialog is created.
 */
'use client';

import { useEffect, useId, useRef, useState, type ReactNode } from 'react';

export interface CodeFieldProps {
  /** Current code; required. Never interpreted as authorization. */
  value: string;
  /** Receives the normalized value; required. */
  onChange: (value: string) => void;
  /** Input presentation. @default "digits" */
  mode?: 'digits' | 'passphrase';
  /** Maximum digit count, from 1 to 16. Ignored for passphrases. @default 8 */
  length?: number;
  /** Uses a native password input and masked segments. @default false */
  masked?: boolean;
  /** Visual state only; pending disables editing. @default "idle" */
  status?: 'idle' | 'pending' | 'error' | 'success';
  /** Announced error text supplied by the consumer. */
  error?: ReactNode;
  /** Visible and accessible field name. @default "Access code" */
  label?: string;
  /** Optional hint connected to the input. */
  description?: ReactNode;
  /** Optional passphrase placeholder. */
  placeholder?: string;
  /** Focus after mount. @default true */
  autoFocus?: boolean;
  /** Native form field name. @default "code" */
  name?: string;
  /** Explicit theme; omitted follows system preference. */
  theme?: 'light' | 'dark';
  /** Additional wrapper classes. */
  className?: string;
}

/** Removes message wrappers, invisible formatting and digit separators. */
export function normalizeCode(
  value: string,
  mode: 'digits' | 'passphrase' = 'digits',
  length = 8,
): string {
  let clean = value
    .replace(/[\u200B-\u200D\u2060\uFEFF]/g, '')
    .trim()
    .replace(/^(?:access\s+code|code|passphrase)\s*:\s*/i, '');
  if (/^["“‘'].*["”’']$/s.test(clean)) clean = clean.slice(1, -1).trim();
  return mode === 'digits'
    ? clean.replace(/[^0-9]/g, '').slice(0, Math.max(1, Math.min(16, Math.floor(length) || 8)))
    : clean;
}

export function CodeField({
  value,
  onChange,
  mode = 'digits',
  length = 8,
  masked = false,
  status = 'idle',
  error,
  label = 'Access code',
  description,
  placeholder,
  autoFocus = true,
  name = 'code',
  theme,
  className = '',
}: CodeFieldProps) {
  const id = useId();
  const input = useRef<HTMLInputElement>(null);
  const slots = useRef<HTMLDivElement>(null);
  const [position, setPosition] = useState(0);
  const [focused, setFocused] = useState(false);
  const count = Math.max(1, Math.min(16, Math.floor(length) || 8));
  const placeCaret = (field: HTMLInputElement, index: number) => {
    const caret = Math.max(0, Math.min(index, value.length, count));
    field.setSelectionRange(caret, caret);
    setPosition(caret);
  };
  useEffect(() => {
    if (autoFocus) input.current?.focus();
  }, [autoFocus]);
  return (
    <div data-knock-code="" data-theme={theme} className={`w-full ${className}`}>
      <style>{`
      [data-knock-code]{--kc-bg:#fffefa;--kc-ink:#292b27;--kc-muted:#666960;--kc-border:#d4d5cc;--kc-accent:#a3462d;--kc-error:#a42c28;color:var(--knock-ink,var(--kc-ink));color-scheme:light}
      [data-knock-code][data-theme=dark]{--kc-bg:#272a27;--kc-ink:#f4f2eb;--kc-muted:#b3b7ac;--kc-border:#60665d;--kc-accent:#f1a187;--kc-error:#ffa69e;color-scheme:dark}
      @media(prefers-color-scheme:dark){[data-knock-code]:not([data-theme=light]){--kc-bg:#272a27;--kc-ink:#f4f2eb;--kc-muted:#b3b7ac;--kc-border:#60665d;--kc-accent:#f1a187;--kc-error:#ffa69e;color-scheme:dark}}
      [data-knock-code] .kc-native:focus-visible{outline:2px solid var(--knock-accent,var(--kc-accent));outline-offset:5px}
      [data-knock-code] .kc-native.absolute:focus-visible{outline:none}
      [data-knock-code] .kc-slot{background:var(--knock-bg,var(--kc-bg));border:1px solid var(--knock-border,var(--kc-border))}
      [data-knock-code] .kc-slot[data-active=true]{border-color:var(--knock-accent,var(--kc-accent));box-shadow:0 0 0 3px color-mix(in srgb,var(--knock-accent,var(--kc-accent)) 12%,transparent)}
      [data-knock-code][data-error=true] .kc-slot{border-color:var(--knock-error,var(--kc-error))}
    `}</style>
      <label htmlFor={id} className="mb-3 block text-[13px] font-medium">
        {label}
      </label>
      <div className="relative" data-error={status === 'error' || undefined}>
        {mode === 'digits' && (
          <div ref={slots} aria-hidden="true" className="flex gap-1.5 sm:gap-2">
            {Array.from({ length: count }, (_, i) => (
              <span
                key={i}
                data-active={focused && i === Math.min(position, count - 1)}
                className="kc-slot flex h-14 min-w-0 flex-1 items-center justify-center rounded-lg font-mono text-xl"
                style={{
                  marginLeft: count >= 4 && count % 2 === 0 && i === count / 2 ? 8 : 0,
                  borderColor:
                    status === 'error' ? 'var(--knock-error,var(--kc-error))' : undefined,
                }}
              >
                {value[i] ? (
                  masked ? (
                    '•'
                  ) : (
                    value[i]
                  )
                ) : (
                  <span className="h-1 w-1 rounded-full bg-[var(--knock-border,var(--kc-border))]" />
                )}
              </span>
            ))}
          </div>
        )}
        <input
          ref={input}
          id={id}
          name={name}
          type={masked ? 'password' : 'text'}
          value={value}
          placeholder={placeholder}
          autoComplete="one-time-code"
          inputMode={mode === 'digits' ? 'numeric' : 'text'}
          spellCheck={false}
          autoCapitalize="none"
          disabled={status === 'pending'}
          aria-invalid={status === 'error' || undefined}
          aria-describedby={
            [description ? `${id}-hint` : '', error ? `${id}-error` : '']
              .filter(Boolean)
              .join(' ') || undefined
          }
          onFocus={(e) => {
            setFocused(true);
            if (
              mode === 'digits' &&
              e.currentTarget.selectionStart !== e.currentTarget.selectionEnd
            )
              placeCaret(e.currentTarget, e.currentTarget.value.length);
          }}
          onBlur={() => setFocused(false)}
          onPointerDown={(e) => {
            if (mode !== 'digits' || e.button !== 0) return;
            e.preventDefault();
            const field = e.currentTarget;
            field.focus();
            const boxes = slots.current?.children;
            if (!boxes?.length) return;
            const index = Array.from(boxes).findIndex(
              (box) => e.clientX <= box.getBoundingClientRect().right,
            );
            placeCaret(field, index < 0 ? count : index);
          }}
          onSelect={(e) => {
            if (e.currentTarget.selectionStart === e.currentTarget.selectionEnd)
              setPosition(e.currentTarget.selectionStart ?? 0);
          }}
          onChange={(e) =>
            onChange(
              mode === 'digits' ? normalizeCode(e.target.value, mode, count) : e.target.value,
            )
          }
          onPaste={(e) => {
            e.preventDefault();
            const field = e.currentTarget;
            const pasted = normalizeCode(e.clipboardData.getData('text'), mode, count);
            const start = field.selectionStart ?? value.length;
            const end = field.selectionEnd ?? start;
            const next =
              mode === 'digits' && pasted.length >= count
                ? pasted
                : value.slice(0, start) + pasted + value.slice(end);
            onChange(mode === 'digits' ? next.slice(0, count) : next);
            const caret =
              mode === 'digits' && pasted.length >= count
                ? count
                : Math.min(start + pasted.length, mode === 'digits' ? count : next.length);
            requestAnimationFrame(() => {
              field.setSelectionRange(caret, caret);
              setPosition(caret);
            });
          }}
          className={
            mode === 'digits'
              ? 'kc-native absolute inset-0 h-full w-full rounded-lg border-0 bg-transparent text-transparent caret-transparent selection:bg-transparent disabled:cursor-wait'
              : 'kc-native h-14 w-full rounded-lg border border-[var(--knock-border,var(--kc-border))] bg-[var(--knock-bg,var(--kc-bg))] px-4 text-base text-[var(--knock-ink,var(--kc-ink))] disabled:cursor-wait'
          }
        />
      </div>
      {description && (
        <div id={`${id}-hint`} className="mt-3 text-xs text-[var(--knock-muted,var(--kc-muted))]">
          {description}
        </div>
      )}
      <div
        id={`${id}-error`}
        role="status"
        aria-live="polite"
        className="mt-2 text-xs text-[var(--knock-error,var(--kc-error))]"
      >
        {status === 'error' ? error : null}
      </div>
    </div>
  );
}

More from knock-codes

Browse all components in this library →