InputOtp | CoUI
LogoCoUI

InputOtp

OTP 인증 코드를 입력하는 분리된 입력 필드 컴포넌트

InputOtp#

OTP(일회용 비밀번호) 인증 코드를 자리수별로 분리된 입력 필드로 입력하는 컴포넌트입니다. Flutter/Web 양쪽에서 동일한 API(InputOtp)를 제공합니다.

Live Preview#

사용 시기 (When to Use)#

이 컴포넌트를 사용하세요:

  • SMS 또는 이메일로 전송된 인증 코드를 입력받을 때
  • 앱 잠금 해제를 위한 PIN 번호를 입력받을 때
  • 2단계 인증(2FA) 코드 입력 화면을 구현할 때
  • 자리수가 고정되어 있고 자동 다음 필드 이동이 필요할 때

대신 다른 컴포넌트를 사용하세요:

  • TextField: 자리수가 정해지지 않은 일반 비밀번호나 코드 입력
  • Form: 여러 입력 필드를 하나의 폼으로 묶어야 할 때

기본 사용법 (Basic Usage)#

Flutter와 Web 모두 동일한 InputOtp 클래스를 사용합니다.

// 6자리 OTP (3자리마다 구분선)
InputOtp(
  length: 6,
  onCompleted: (code) => print(code),
)

// PIN 입력 (4자리, 구분선 없음, 입력값 숨김)
InputOtp(
  length: 4,
  obscured: true,
  separatorInterval: 0,
  onCompleted: handlePinCompleted,
)

// 초기값 복원
InputOtp(
  length: 6,
  initialValue: [49, 50, 51, 52, 53, 54], // "123456" codepoints
  onChanged: handleChanged,
)
// 6자리 OTP
InputOtp(
  length: 6,
  onCompleted: (code) => print(code),
)

// PIN 입력 (4자리, 구분선 없음, 입력값 숨김)
InputOtp(
  length: 4,
  obscured: true,
  separatorInterval: 0,
  onCompleted: handlePinCompleted,
)

// 초기값 복원
InputOtp(
  length: 6,
  initialValue: '123456',
  onChanged: handleChanged,
)

빠른 오버라이드 (Chain)#

이미 만든 InputOtp 인스턴스에 스타일을 빠르게 덧붙이고 싶다면 withStyle 체인을 쓸 수 있습니다. 생성자의 style 슬롯 인자와 동일하게 동작하지만, 이미 구성된 위젯 위에서 바로 이어 쓸 수 있습니다. .radius4처럼 Core 토큰 상수 이름과 똑같은 이름의 getter도 있습니다 — withStyle을 한 번 더 줄인 sugar로, 이름이 곧 값이라(radius4 == CoreRadius.radius4) 어느 컴포넌트에서 써도 뜻이 갈리지 않습니다.

class InputOtpChainExample extends StatelessWidget {
  const InputOtpChainExample({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      mainAxisSize: MainAxisSize.min,
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        // 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
        InputOtp(length: 6, onCompleted: (_) {}).radius16.primary,
        const Gap.space12(),
        // withStyle full-control — getter 가 없는 필드(borderColor·borderWidth)까지 한 번에.
        InputOtp(length: 6, onCompleted: (_) {}).withStyle(
          const CoreInputOtpStyle(
            backgroundColor: CoreColor.token(CoreColors.surfaceContainer),
            borderColor: CoreColor.token(CoreColors.tertiary),
            borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
            borderWidth: CoreStrokeWidth.stroke2,
          ),
        ),
      ],
    );
  }
}
class InputOtpChainExample extends StatelessComponent {
  const InputOtpChainExample({super.key});

  @override
  Component build(BuildContext context) {
    return div(
      [
        // 토큰-정확 getter combo — 이름이 곧 값 (getter 이름 = Core 토큰 상수 1:1).
        InputOtp(length: 6).radius16.primary,
        const Gap.space12(),
        // withStyle full-control — getter 가 없는 필드(borderColor·borderWidth)까지 한 번에.
        InputOtp(length: 6).withStyle(
          const CoreInputOtpStyle(
            backgroundColor: CoreColor.token(CoreColors.surfaceContainer),
            borderColor: CoreColor.token(CoreColors.tertiary),
            borderRadius: CoreBorderRadius.all(CoreRadius.radius24),
            borderWidth: CoreStrokeWidth.stroke2,
          ),
        ),
      ],
      classes: 'flex flex-col items-start',
    );
  }
}

Props / Parameters#

InputOtp은 Flutter/Web에서 동일한 파라미터 이름을 사용합니다. 타입만 플랫폼에 맞게 다릅니다.

속성Flutter 타입Web 타입기본값설명
length int int 6 OTP 자리수
separatorInterval int int 3 N자리마다 구분선 삽입 (0 = 없음)
obscured bool bool false 입력값 숨김 여부 (PIN 모드)
enabled bool bool true 상호작용 활성화
autofocus bool bool false 마운트 시 자동 포커스 (SMS 인증 화면 진입 즉시 키보드 표시)
initialValue List<int?>? String? null 초기 OTP 값
errorText String? String? null 에러 메시지. 값이 있으면 셀 보더가 에러 색으로 바뀌고 메시지가 아래에 표시됨
onChanged ValueChanged<List<int?>>? CoreValueChanged<String>? null 값 변경 콜백
onCompleted ValueChanged<List<int?>>? CoreValueChanged<String>? null 모든 자리 입력 완료 콜백
inputOtpStyle CoreInputOtpStyle? CoreInputOtpStyle? null 셀 크기 / 색 / radius / 텍스트 chrome override — 모든 chrome 이 흐르는 단일 슬롯

스타일 시스템 (Style System)#

모든 chrome / dimensional / 텍스트 override 는 inputOtpStyle 슬롯 하나로 흐릅니다.

CoreInputOtpStyle 필드#

필드타입설명
cellSpacing double? Gap between sibling OTP cells (logical px). null defers to [defaultCellSpacing]. Consumed natively as Row.spacing on Flutter and gap-${cellSpacing.tailwindSpace} on Web.
cellSize double? Width and height of each OTP cell (logical px).
obscuredDotSize double? Size (logical px, square) of the obscured-character dot. null → [defaultObscuredDotSize]. Flutter renders the dot at this size; resolver pre-applies scaling.
height double? Overall height of the OTP input row (logical px).
backgroundColor CoreColor? Background colour of each OTP cell.
borderColor CoreColor? Border colour of each OTP cell.
focusBackgroundColor CoreColor? Background colour when a cell is focused. null follows [backgroundColor], whose own fallback is [defaultFocusBackgroundColor].
focusBorderColor CoreColor? Border colour when a cell is focused.
borderRadius CoreBorderRadius? Border radius of each OTP cell.
borderWidth double? Border stroke width of each OTP cell (logical px) — defaults to [defaultBorderWidth].
separatorPadding CoreEdgeInsets? Padding around the separator glyph — defaults to [defaultSeparatorPadding].
transitionDuration Duration? Transition duration for cell focus / value changes — defaults to [defaultTransitionDuration].
textStyle CoreTextStyle? Text style applied to the OTP input value. Layered on top of [defaultTextStyle].
separatorTextStyle CoreTextStyle? Text style applied to the separator glyph ( - ). Layered on top of [defaultSeparatorTextStyle].
errorTextStyle CoreTextStyle? Text style applied to the error helper message (rendered when widget.errorText != null ). Layered on top of [defaultErrorTextStyle].
errorTextPadding CoreEdgeInsets? Padding above the error helper message — defaults to [defaultErrorTextPadding].

테마 커스터마이징 (Theme)#

CoreInputOtpTheme으로 프로젝트 수준 스타일 오버라이드가 가능합니다.

CoreComponentTheme(
  inputOtp: CoreInputOtpTheme(
    style: CoreInputOtpStyle(
      cellSize: 44,              // 기본: CoreSpace.space40 (40px)
      cellSpacing: 12,           // 기본: CoreSpace.space8 (8px)
      height: 44,                // 기본: cellSize와 동일
      borderRadius: CoreBorderRadius.all(CoreRadius.radius8),
      backgroundColor: coreColor,   // 기본: surface
      borderColor: coreColor,       // 기본: outlineVariant
      focusBackgroundColor: coreColor,
      focusBorderColor: coreColor,
      textStyle: coreTextStyle,
    ),
  ),
)

Resolve 우선순위: 위젯 파라미터 > CoreInputOtpTheme > 디자인 시스템 기본값.

동작 스펙 (Behavior)#

아키텍처 — 단일 히든 입력 (Flutter/Web 공통)#

InputOtp는 화면에 보이는 N개의 칸을 각각 별도의 입력 필드로 만들지 않는다. 실제 키보드/자동완성 연결은 보이지 않는 입력 필드 하나가 소유하고, 화면에 보이는 N개의 칸은 그 값을 그대로 반영해 그리는 순수 표시용 요소다(Flutter: 투명 TextField 위에 시각 셀을 겹쳐 그림, Web: opacity:0<input> 하나가 행 전체를 덮고 그 아래 시각 셀 <div>들은 pointer-events: none).

왜 단일 입력인가: 칸마다 별도 입력 필드를 두면 타이핑할 때마다 포커스가 필드 사이를 옮겨 다니게 되는데, 이는 iOS/Android의 SMS 자동완성(자동채움) 연결을 매 키 입력마다 끊어버려 실제 기기에서 자동완성이 되다 말다 하는 문제를 일으킨다. 단일 입력 필드를 유지하면 OS/브라우저의 OTP 자동완성 제안이 안정적으로 동작한다 — 이것이 실제 SMS 인증에 사용할 때 가장 중요한 개선점이다.

인터랙션#

  • 자동 이동: 필요 없음 — 단일 입력이므로 숫자를 계속 치면 자연스럽게 다음 자리로 이어진다
  • 아무 칸이나 선택해도 커서는 항상 입력된 값의 끝으로: 중간 칸(비어 있는 칸 포함)을 클릭/탭해도 실제 캐럿은 항상 지금까지 입력한 값의 마지막 위치로 이동한다 — 숨겨진 입력 필드의 실제 글자 배치는 화면에 보이는 칸 격자와 무관하기 때문에, 브라우저/OS 의 기본 클릭 위치 기반 캐럿 배치를 그대로 두면 의미 없는 위치에 커서가 놓인다. Flutter 는 TextField.onTap + 포커스 획득 시, Web 은 click/focus 이벤트에서 캐럿을 값 끝으로 강제 이동시켜 이 문제를 막는다.
  • 역방향 이동: Backspace 는 마지막 한 자리를 지우고, 활성(포커스 링) 칸이 한 칸씩 앞으로 이동한다 — 캐럿이 항상 끝에 있으므로 단일 입력의 네이티브 Backspace 동작만으로 자연스럽게 동작 (화살표 키도 네이티브 캐럿 이동, 양 플랫폼 모두 별도 로직 없음)
  • 붙여넣기: 클립보드 내용에서 숫자만 추출해 자동 채움 — 숫자가 아닌 문자(예: SMS 메시지 전체를 복사해 붙여넣은 경우의 안내 문구)는 무시되므로 "인증번호는 123456 입니다" 같은 문자열을 통째로 붙여넣어도 코드만 추출된다
  • SMS 자동완성: Flutter는 AutofillHints.oneTimeCode(iOS UITextContentType.oneTimeCode / Android SMS Retriever), Web은 autocomplete="one-time-code" + WebOTP API(지원 브라우저에서 SMS 수신 시 자동으로 값 채움, 미지원 브라우저는 조용히 무시되고 수동 입력/붙여넣기로 동작)
  • 완료 감지: 모든 자리 입력 완료 시 onCompleted 호출
  • 완료 시 키보드 해제: 마지막 자리 입력으로 onCompleted가 발생하면 입력 필드가 자동으로 포커스를 해제해 모바일 소프트 키보드를 내림 (Flutter/Web 공통)

상태 전환#

  • emptyfilling (첫 칸 입력 시작)
  • fillingcompleted (모든 자리 입력)
  • completedfilling (Backspace로 마지막 자리 삭제)

유효성#

  • 기본 숫자만 입력 (inputmode="numeric" / keyboardType.number)
  • enabled: false 시 모든 인터랙션 차단
  • errorText 가 설정되면 셀 보더(및 포커스 링)가 에러 색으로 바뀌고 메시지가 셀 아래 표시됨

사용 가이드라인 (Usage Guidelines)#

✅ Do#

PIN 입력 시 obscured: true로 보안 강화

InputOtp(
  length: 4,
  obscured: true,
  separatorInterval: 0,
  onCompleted: handlePinVerify,
)

PIN은 민감 정보이므로 화면 노출을 방지한다.


SMS 인증 화면에서는 autofocus: true로 진입 즉시 입력 가능하게

InputOtp(
  length: 6,
  autofocus: true,
  onCompleted: handleVerify,
)

화면이 뜨자마자 키보드가 올라와 있어야 사용자가 바로 입력을 시작할 수 있다.


❌ Don't#

인증 실패 후 에러 표시 없이 기존 입력 유지

// ❌ 실패 후 코드가 남아있어 혼란 야기, errorText 미사용
InputOtp(
  length: 6,
  onCompleted: (otp) async {
    final ok = await authService.verify(otp);
    // 실패 시 초기화/에러 메시지 없음
  },
)

errorText 로 실패를 명확히 표시하고, 재시도를 위해 새 key 를 발급해 입력을 초기화한다(컴포넌트는 uncontrolled 모델이라 key 교체가 리셋 방법이다).

// ✅ errorText 로 에러 표시 + key 교체로 리셋
InputOtp(
  key: ValueKey(attemptCount),
  length: 6,
  errorText: verifyFailed ? '인증번호가 일치하지 않습니다' : null,
  onCompleted: (otp) async {
    final ok = await authService.verify(otp);
    setState(() {
      verifyFailed = !ok;
      if (!ok) attemptCount++; // key 변경 → 입력 초기화
    });
  },
)

❌ Don't#

OTP 자리수를 8자리 이상으로 설정

8자리를 초과하면 모바일 레이아웃이 깨지고 입력 피로도가 급증한다. 일반적으로 4~8자리가 적절하다.

접근성 (Accessibility)#

키보드 인터랙션#

단일 히든 입력이 실제 포커스를 가지므로 모든 키 동작은 네이티브 텍스트 입력 편집 동작 그대로다(별도 구현 없음).

동작
0-9값에 자리 추가 (네이티브 입력 동작)
Backspace마지막 자리 삭제 (네이티브 입력 동작)
/ 히든 입력 내부 캐럿 이동 (네이티브 입력 동작)
Ctrl+V붙여넣은 내용에서 숫자만 추출해 자동 채움

스크린 리더#

  • 전체 컨테이너: role="group", aria-label="One-time password input"
  • 실제 포커스 가능 요소는 히든 입력 하나뿐이며 aria-label="One-time password input" 을 가진다 — 화면에 보이는 N개의 칸은 aria-hidden="true" 인 순수 표시 요소라 스크린 리더가 칸을 하나씩 탭하며 읽지 않는다(단일 입력 필드를 하나의 코드로 다루는 편이 자리마다 별도 텍스트 필드를 탭하는 것보다 접근성상 더 낫다는 것이 일반적인 OTP 폼 접근성 권고다).
  • TextField: 자리수 고정이 아닌 일반 코드 입력
  • Form: OTP를 포함한 인증 폼 구성