InputOtp#
OTP(일회용 비밀번호) 인증 코드를 자리수별로 분리된 입력 필드로 입력하는 컴포넌트입니다. Flutter/Web 양쪽에서 동일한 API(InputOtp)를 제공합니다.
Live Preview#
class InputOtpDefaultExample extends StatelessComponent {
const InputOtpDefaultExample({super.key});
@override
Component build(BuildContext context) {
return InputOtp(length: 6);
}
}
class InputOtpDefaultExample extends StatelessWidget {
const InputOtpDefaultExample({super.key});
@override
Widget build(BuildContext context) {
return InputOtp(
length: 6,
onCompleted: (_) {},
);
}
}
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',
);
}
}
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,
),
),
],
);
}
}
사용 시기 (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(iOSUITextContentType.oneTimeCode/ Android SMS Retriever), Web은autocomplete="one-time-code"+ WebOTP API(지원 브라우저에서 SMS 수신 시 자동으로 값 채움, 미지원 브라우저는 조용히 무시되고 수동 입력/붙여넣기로 동작) - 완료 감지: 모든 자리 입력 완료 시
onCompleted호출 -
완료 시 키보드 해제: 마지막 자리 입력으로
onCompleted가 발생하면 입력 필드가 자동으로 포커스를 해제해 모바일 소프트 키보드를 내림 (Flutter/Web 공통)
상태 전환#
empty→filling(첫 칸 입력 시작)filling→completed(모든 자리 입력)completed→filling(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 폼 접근성 권고다).