Bỏ qua đến nội dung

Nhập OTP

Nhập mã dùng một lần cho xác thực hai yếu tố (2FA), xác minh đăng nhập và màn hình nhập mã PIN — các ô hỗ trợ dán mã với viền theo phong cách tân brutalist và hiệu ứng bóng đậm.

import {
  InputOTP,
  InputOTPGroup,

Input OTP là ô nhập mã dùng một lần được chia thành từng ô: các ô hiển thị được vẽ lên trên một input native duy nhất, nên việc dán mã và tự động điền mã trên mobile hoạt động ngay từ đầu. Cả hai biến thể backend đều bọc cùng một thư viện input-otp của @guilherme_rodz — bên dưới không có primitive nào của Radix hay Base — với các ô được tạo kiểu theo công thức neobrutalist: viền dày, bóng đổ đậm và kiểu chữ đậm.

Nên dùng khi:

  • Xác thực hai yếu tố — mã TOTP hoặc mã SMS 6 chữ số sau khi đăng nhập bằng mật khẩu.
  • Xác minh email và số điện thoại — xác nhận quyền sở hữu khi đăng ký, thanh toán hoặc khôi phục tài khoản.
  • Nhập mã PIN — mã 4 chữ số cho khóa màn hình hoặc xác nhận thanh toán (xem ví dụ Bốn chữ số).

Giới thiệu

Input OTP được xây dựng dựa trên input-otp của @guilherme_rodz.

Cài đặt

pnpm dlx shadcn@latest add https://neobrutalism.com/r/base/input-otp.json
npx shadcn@latest add https://neobrutalism.com/r/base/input-otp.json
yarn dlx shadcn@latest add https://neobrutalism.com/r/base/input-otp.json
bunx --bun shadcn@latest add https://neobrutalism.com/r/base/input-otp.json

Cài đặt các phụ thuộc sau:

pnpm add input-otp
npm install input-otp
yarn add input-otp
bun add input-otp

Sao chép và dán đoạn mã sau vào dự án của bạn.

components/ui/input-otp.tsx
"use client"

import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { MinusIcon } from "lucide-react"

import { cn } from "@/lib/utils"

function InputOTP({
  className,
  containerClassName,
  ...props
}: React.ComponentProps<typeof OTPInput> & {
  containerClassName?: string
}) {
  return (
    <OTPInput
      data-slot="input-otp"
      containerClassName={cn(
        "cn-input-otp flex items-center has-disabled:opacity-50",
        containerClassName
      )}
      spellCheck={false}
      className={cn("disabled:cursor-not-allowed", className)}
      {...props}
    />
  )
}

function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="input-otp-group"
      className={cn(
        "flex items-center rounded has-aria-invalid:border-destructive",
        className
      )}
      {...props}
    />
  )
}

function InputOTPSlot({
  index,
  className,
  ...props
}: React.ComponentProps<"div"> & {
  index: number
}) {
  const inputOTPContext = React.useContext(OTPInputContext)
  const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}

  return (
    <div
      data-slot="input-otp-slot"
      data-active={isActive}
      className={cn(
        "relative flex size-8 items-center justify-center border-y-2 border-r-2 bg-input text-sm shadow-sm transition-all outline-none first:rounded-l first:border-l-2 last:rounded-r aria-invalid:border-destructive data-[active=true]:z-10 data-[active=true]:outline-2 data-[active=true]:outline-primary data-[active=true]:aria-invalid:border-destructive",
        className
      )}
      {...props}
    >
      {char}
      {hasFakeCaret && (
        <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
          <div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
        </div>
      )}
    </div>
  )
}

function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="input-otp-separator"
      className="flex items-center [&_svg:not([class*='size-'])]:size-4"
      role="separator"
      {...props}
    >
      <MinusIcon />
    </div>
  )
}

export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

Cập nhật các đường dẫn import cho phù hợp với thiết lập dự án của bạn.

Cách dùng

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>

Kết hợp

Dùng cách kết hợp sau để xây dựng một InputOTP:

InputOTP
├── InputOTPGroup
│   ├── InputOTPSlot
│   ├── InputOTPSlot
│   └── InputOTPSlot
├── InputOTPSeparator
├── InputOTPGroup
│   ├── InputOTPSlot
│   ├── InputOTPSlot
│   └── InputOTPSlot
├── InputOTPSeparator
└── InputOTPGroup
    ├── InputOTPSlot
    └── InputOTPSlot

Pattern

Dùng prop pattern để định nghĩa một mẫu tùy chỉnh cho ô nhập OTP.

import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp"
 
;<InputOTP maxLength={6} pattern={REGEXP_ONLY_DIGITS_AND_CHARS}>
  ...
</InputOTP>
"use client"

import { REGEXP_ONLY_DIGITS } from "input-otp"

Ví dụ

Dấu phân tách

Dùng thành phần <InputOTPSeparator /> để thêm một dấu phân tách giữa các nhóm ô nhập.

import {
  InputOTP,
  InputOTPGroup,

Vô hiệu hóa

Dùng prop disabled để vô hiệu hóa ô nhập.

import { Field, FieldLabel } from "@/components/ui/field"
import {
  InputOTP,

Được kiểm soát

Dùng các prop valueonChange để kiểm soát giá trị của ô nhập.

"use client"

import * as React from "react"

Không hợp lệ

Dùng aria-invalid trên các slot để hiển thị trạng thái lỗi.

"use client"

import * as React from "react"

Bốn chữ số

Một mẫu phổ biến cho mã PIN. Nó dùng prop pattern={REGEXP_ONLY_DIGITS}.

"use client"

import { REGEXP_ONLY_DIGITS } from "input-otp"

Chữ và số

Dùng REGEXP_ONLY_DIGITS_AND_CHARS để chấp nhận cả chữ cái và số.

"use client"

import { REGEXP_ONLY_DIGITS_AND_CHARS } from "input-otp"

Form

import { RefreshCwIcon } from "lucide-react"

import { Button } from "@/components/ui/button"

RTL

Để bật hỗ trợ RTL trong Neobrutalism, hãy xem hướng dẫn cấu hình RTL.

"use client"

import * as React from "react"

Khả năng truy cập

Không có mẫu WAI-ARIA APG nào cho ô nhập OTP, và cũng không cần đến: input-otp render một thẻ <input> thật với autocomplete="one-time-code" nằm sau các ô hiển thị, nên công nghệ hỗ trợ chỉ đọc lên một ô văn bản duy nhất và bàn phím trên mobile có thể gợi ý mã vừa nhận được. Các ô hiển thị chỉ mang tính trình bày — hãy gắn nhãn cho chính ô nhập (bằng FormLabel hoặc aria-label), và truyền aria-invalid xuống các ô để tạo kiểu cho trạng thái lỗi.

Tương tác bàn phím theo đúng hành vi của ô văn bản native và giống nhau ở cả biến thể Radix và Base:

PhímHành động
Tab / Shift + TabChuyển focus vào / ra khỏi ô nhập (chỉ một điểm dừng tab)
ArrowLeft / ArrowRightDi chuyển con trỏ giữa các ô
Home / EndNhảy đến ô đầu / ô cuối
Backspace / DeleteXóa ký tự trước / ký tự sau
Ctrl/Cmd + VDán — mã sẽ điền vào các ô nếu khớp với pattern

Tham chiếu API

Xem tài liệu input-otp để biết thêm thông tin.