Przejdź do treści

Input OTP

Segmentowane pole na kody jednorazowe, PIN-y i kody odzyskiwania — z obsługą wklejania, autouzupełniania z SMS-ów i menedżerów haseł.

Kod źródłowyRejestr
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSeparator,
  InputOTPSlot,
} from "@/components/ui/input-otp"

export default function InputOTPDemo() {
  return (
    <InputOTP maxLength={6} aria-label="Kod weryfikacyjny">
      <InputOTPGroup>
        <InputOTPSlot index={0} />
        <InputOTPSlot index={1} />
        <InputOTPSlot index={2} />
      </InputOTPGroup>
      <InputOTPSeparator />
      <InputOTPGroup>
        <InputOTPSlot index={3} />
        <InputOTPSlot index={4} />
        <InputOTPSlot index={5} />
      </InputOTPGroup>
    </InputOTP>
  )
}

Anatomia

  1. 1GrupaCiąg pól, które dzielą obramowania, dzięki czemu grupa wygląda jak jedno pole.
  2. 2Pole znakuJeden znak. Pole, w którym właśnie się pisze, przyjmuje kolor fokusu i pokazuje kursor.
  3. 3SeparatorPrzerwa między grupami, tak jak w kodzie zapisanym jako 123 456.

Instalacja

$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/input-otp.json

Użycie

import {
  InputOTP,
  InputOTPGroup,
  InputOTPSeparator,
  InputOTPSlot,
} from "@/components/ui/input-otp"
<InputOTP maxLength={6}>
  <InputOTPGroup>
    <InputOTPSlot index={0} />
    <InputOTPSlot index={1} />
    <InputOTPSlot index={2} />
  </InputOTPGroup>
  <InputOTPSeparator />
  <InputOTPGroup>
    <InputOTPSlot index={3} />
    <InputOTPSlot index={4} />
    <InputOTPSlot index={5} />
  </InputOTPGroup>
</InputOTP>

Input OTP jest zbudowany na input-otp: sloty to tylko obraz jednego prawdziwego pola, które leży pod spodem. Dlatego działają wklejanie, autouzupełnianie z SMS-ów, menedżery haseł, cofanie zmian i czytniki ekranu — do wypełnienia jest jedno pole, a nie sześć.

Przykłady

Tylko cyfry

Przekaż pattern, żeby odrzucać niepasujące znaki już podczas pisania. REGEXP_ONLY_DIGITS pasuje do PIN-ów i kodów z SMS-ów, a domyślny inputMode="numeric" wyświetla na telefonach klawiaturę numeryczną. Wzorce są reeksportowane z @/components/ui/input-otp; podobnie jak samego komponentu, używaj ich w komponentach klienckich.

"use client"

import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
  REGEXP_ONLY_DIGITS,
} from "@/components/ui/input-otp"

export default function InputOTPDigitsOnly() {
  return (
    <Field className="w-fit">
      <FieldLabel htmlFor="input-otp-pin">PIN karty</FieldLabel>
      <InputOTP
        id="input-otp-pin"
        maxLength={4}
        pattern={REGEXP_ONLY_DIGITS}
        inputMode="numeric"
      >
        <InputOTPGroup>
          <InputOTPSlot index={0} />
          <InputOTPSlot index={1} />
          <InputOTPSlot index={2} />
          <InputOTPSlot index={3} />
        </InputOTPGroup>
      </InputOTP>
      <FieldDescription>
        Litery i symbole są pomijane podczas wpisywania.
      </FieldDescription>
    </Field>
  )
}

Litery i cyfry

W kodach odzyskiwania dopuść litery wzorcem REGEXP_ONLY_DIGITS_AND_CHARS i przełącz inputMode na text, żeby dało się je wpisać z klawiatury telefonu. pasteTransformer czyści wklejane wartości — tutaj usuwa myślniki i zamienia litery na wielkie.

"use client"

import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSeparator,
  InputOTPSlot,
  REGEXP_ONLY_DIGITS_AND_CHARS,
} from "@/components/ui/input-otp"

export default function InputOTPAlphanumeric() {
  return (
    <Field className="w-fit">
      <FieldLabel htmlFor="input-otp-recovery">Kod odzyskiwania</FieldLabel>
      <InputOTP
        id="input-otp-recovery"
        maxLength={8}
        pattern={REGEXP_ONLY_DIGITS_AND_CHARS}
        inputMode="text"
        pasteTransformer={(pasted) => pasted.replaceAll("-", "").toUpperCase()}
      >
        <InputOTPGroup>
          <InputOTPSlot index={0} />
          <InputOTPSlot index={1} />
          <InputOTPSlot index={2} />
          <InputOTPSlot index={3} />
        </InputOTPGroup>
        <InputOTPSeparator />
        <InputOTPGroup>
          <InputOTPSlot index={4} />
          <InputOTPSlot index={5} />
          <InputOTPSlot index={6} />
          <InputOTPSlot index={7} />
        </InputOTPGroup>
      </InputOTP>
      <FieldDescription>
        Wklej kod, np. 7F2K-9QXA — myślniki zostaną usunięte.
      </FieldDescription>
    </Field>
  )
}

Tryb kontrolowany z automatycznym wysłaniem

Kontroluj wartość przez value i onChange, a weryfikuj ją w onComplete, który wywołuje się, gdy wszystkie sloty są wypełnione. Na czas weryfikacji wyłącz pole, po nieudanej próbie zachowaj to, co ktoś wpisał, i napisz, dokąd wysłano kod.

"use client"

import * as React from "react"
import { Button } from "@/components/ui/button"
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
  REGEXP_ONLY_DIGITS,
} from "@/components/ui/input-otp"

export default function InputOTPControlled() {
  const [code, setCode] = React.useState("")
  const [status, setStatus] = React.useState<"idle" | "verifying" | "verified">(
    "idle"
  )

  // Stands in for the request that checks the code; cleared on unmount.
  React.useEffect(() => {
    if (status !== "verifying") return
    const timer = setTimeout(() => setStatus("verified"), 1200)
    return () => clearTimeout(timer)
  }, [status])

  return (
    <Field className="w-fit items-center">
      <FieldLabel htmlFor="input-otp-controlled">
        Wpisz 6-cyfrowy kod
      </FieldLabel>
      <InputOTP
        id="input-otp-controlled"
        maxLength={6}
        pattern={REGEXP_ONLY_DIGITS}
        value={code}
        onChange={(value) => {
          setCode(value)
          setStatus("idle")
        }}
        onComplete={() => setStatus("verifying")}
        disabled={status === "verifying"}
      >
        <InputOTPGroup>
          {Array.from({ length: 6 }, (_, index) => (
            <InputOTPSlot key={index} index={index} />
          ))}
        </InputOTPGroup>
      </InputOTP>
      <FieldDescription aria-live="polite" className="text-center">
        {status === "verifying" && "Weryfikowanie…"}
        {status === "verified" && "Zweryfikowano. Zalogowano pomyślnie."}
        {status === "idle" && "Wysłaliśmy go na adres ada@prfct.dev."}
      </FieldDescription>
      <Button
        variant="link"
        size="sm"
        disabled={status === "verifying"}
        onClick={() => {
          setCode("")
          setStatus("idle")
        }}
      >
        Wyślij kod ponownie
      </Button>
    </Field>
  )
}

Stan błędu

Ustaw aria-invalid na InputOTP, a wszystkie sloty zmienią kolor na czerwony — nie trzeba oznaczać ich pojedynczo. Wyjaśnij problem w FieldError powiązanym przez aria-describedby.

4
8
1
2
0
9
"use client"

import * as React from "react"

import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
} from "@/components/ui/input-otp"

export default function InputOTPInvalid() {
  const [code, setCode] = React.useState("481209")

  return (
    <Field data-invalid className="w-fit">
      <FieldLabel htmlFor="input-otp-invalid">Kod weryfikacyjny</FieldLabel>
      <InputOTP
        id="input-otp-invalid"
        maxLength={6}
        value={code}
        onChange={setCode}
        aria-invalid
        aria-describedby="input-otp-invalid-error"
      >
        <InputOTPGroup>
          {Array.from({ length: 6 }, (_, index) => (
            <InputOTPSlot key={index} index={index} />
          ))}
        </InputOTPGroup>
      </InputOTP>
      <FieldError id="input-otp-invalid-error">
        Ten kod wygasł. Poproś o nowy.
      </FieldError>
    </Field>
  )
}

Wytyczne

Kiedy używać

  • Kody o stałej długości, które ktoś przepisuje z innego miejsca: kody uwierzytelniania dwuskładnikowego, kody weryfikacyjne z e-maili, PIN-y, kody odzyskiwania.

Kiedy nie używać

  • Hasła i wszystko o zmiennej długości — użyj komponentu Input z type="password".
  • Kody dłuższe niż mniej więcej 8 znaków — łatwiej wypełnić pojedyncze pole z czytelną wskazówką co do formatu.

Dziel długie kody

Pamięć krótkotrwała mieści naraz trzy, cztery znaki. Podziel kody sześciocyfrowe na dwie grupy po trzy, a ośmioznakowe na dwie grupy po cztery za pomocą InputOTPSeparator — i zachowaj taki sam podział jak w e-mailu lub SMS-ie, który dostarcza kod.

DobrzeDwie grupy po trzy odpowiadają temu, jak ludzie czytają i zapamiętują kod.
ŹleOsobne pola na każdą cyfrę psują wklejanie, autouzupełnianie i obsługę czytników ekranu.

Wybaczaj błędy

Wysyłaj kod automatycznie po wypełnieniu pola, ale nigdy nie zostawiaj ludzi bez wyjścia: po nieudanej próbie zachowaj to, co wpisali, pozwól poprawić dowolny slot i zamiast ślepego zaułka zaproponuj Wyślij kod ponownie z widocznym czasem oczekiwania.

Dostępność

  • Jedno pole. Czytniki ekranu ogłaszają jedno pole tekstowe i odczytują całą wartość; w kolejności tabulacji pole zajmuje jedno miejsce, a nie po jednym na każdy slot.
  • Podpisz pole. Nadaj InputOTP id, do którego odwoła się FieldLabel, albo aria-label, np. Kod weryfikacyjny.
  • Autouzupełnianie. autoComplete="one-time-code" jest ustawione domyślnie, więc iOS i Android podpowiadają kody z SMS-ów i e-maili.
  • Menedżery haseł. Przy pushPasswordManagerStrategy="increase-width" (wartość domyślna) pole niewidocznie się poszerza, żeby ikony menedżerów haseł nie zasłaniały ostatniego slotu.
  • Kontrast. Obramowania slotów używają tokenu input o kontraście 3:1; aktywny slot dostaje dodatkowo poświatę fokusu w kolorze marki.
KlawiszDziałanie
Tab
Przenosi fokus do pola. Kursor trafia do pierwszego pustego slotu.
0–9 / A–Z
Wypełnia aktywny slot i przechodzi do następnego. Znaki niepasujące do pattern są ignorowane.
Backspace
Usuwa poprzedni znak.
←→
Przesuwa kursor między slotami.
CtrlV⌘V
Wkleja kod i rozkłada go na sloty.

Dokumentacja API

InputOTP

Renderuje kontener i jedno ukryte pole <input>. Przyjmuje wszystkie natywne propsy pola input oraz opcje biblioteki input-otp.

PropTypDomyślnie
maxLengthwymagany

Liczba znaków — musi odpowiadać liczbie slotów.

numberBrak wartości domyślnej
value

Wartość kontrolowana.

stringBrak wartości domyślnej
onChange

Wywoływana z nową wartością przy każdej zmianie.

(value: string) => voidBrak wartości domyślnej
onComplete

Wywoływana, gdy wszystkie sloty są wypełnione.

(value: string) => voidBrak wartości domyślnej
pattern

Wyrażenie regularne, do którego musi pasować każdy znak. Użyj eksportowanych REGEXP_ONLY_DIGITS, REGEXP_ONLY_CHARS lub REGEXP_ONLY_DIGITS_AND_CHARS.

stringBrak wartości domyślnej
pasteTransformer

Czyści wklejony tekst przed walidacją, np. usuwa myślniki.

(pasted: string) => stringBrak wartości domyślnej
inputMode

Wirtualna klawiatura, która ma się pojawić. Dla kodów z literami użyj text.

string"numeric"
textAlign

Gdzie ustawia się kursor, gdy pole dostaje fokus po kliknięciu poza slotem.

"left" | "center" | "right""left"
pushPasswordManagerStrategy

Robi miejsce na ikony menedżerów haseł.

"increase-width" | "none""increase-width"
containerClassName

Klasy zewnętrznego kontenera. className stylizuje ukryte pole.

stringBrak wartości domyślnej
disabled

Wyłącza pole i przygasza wszystkie sloty.

booleanfalse

InputOTPGroup

Renderuje <div>, który łączy swoje sloty w jeden obramowany segment.

InputOTPSlot

Renderuje komórkę na jeden znak. Przyjmuje wszystkie propsy div.

PropTypDomyślnie
indexwymagany

Pozycja znaku wyświetlanego w tym slocie, liczona od zera.

numberBrak wartości domyślnej

InputOTPSeparator

Renderuje <div role="separator"> z kreską między grupami.