주민등록번호·카드번호처럼 일부 구간을 가려야 하는 값을 구간마다 별도 입력으로 나눠 받는 컴포넌트입니다.
import { Box } from "@seed-design/react" ;
import { ResidentNumberField } from "seed-design/ui/resident-number-field" ;
export default function SegmentedTextInputPreview () {
return (
< Box width = "358px" maxWidth = "100%" >
< ResidentNumberField label = "주민등록번호" />
</ Box >
);
}
주민등록번호 뒷자리 첫 번째 자리만 받거나 카드번호 뒷자리를 가려야 하는 경우처럼, 하나의 Text Field로 표현하기 어려운 국내 입력 형식에 사용합니다. 대부분의 형식이 정해진 입력은 Text Field 로 충분합니다. 아래 Text Field와의 차이 를 보고 고르세요.
Segment마다 입력 하나를 두고, 값은 입력 Segment 순서대로 string[]로 다룹니다.
끝에서 입력해 Segment를 채우면 다음 Segment로 이동합니다. 가운데에 넣어 자리 수를 넘는 입력은 반영하지 않습니다.
빈 Segment에서 Backspace를 누르면 이전 Segment의 마지막 글자를 지웁니다. 끝에서 → 키, 처음에서 ← 키를 누르면 옆 Segment로 이동합니다.
가린(masked) Segment는 입력한 글자 대신 자리 수만큼 점을 그립니다. 가린 값 안에서는 caret을 옮기지 않고 끝에서만 입력·삭제하며, ←·→ 키는 바로 옆 Segment로 이동합니다.
붙여넣은 값은 구분자를 지우고 다음 Segment까지 차례로 채웁니다. 가린(masked) Segment 앞에서 멈춥니다.
autoComplete="bday" Segment는 OS가 넣은 생년월일(yyyy-MM-dd 등)을 자리 수에 맞춰 바꿉니다.
한글 조합 중에는 다음 Segment로 이동하지 않습니다.
Text Field는 값 전체를 input 하나로 받고, Segmented Text Input은 Segment마다 input을 따로 둡니다. 이 차이 때문에 두 컴포넌트가 잘하는 일이 다릅니다.
Text Field: 값 전체를 한 번에 선택(Cmd/Ctrl+A)·복사·수정할 수 있고, 붙여넣기와 OS 자동완성이 그대로 동작합니다. 하이픈 같은 구분자는 formatter로 값에 넣어 보여 줍니다.
Segmented Text Input: Segment마다 가리기(masked)·자리 수·placeholder를 다르게 줄 수 있고, 구분자를 입력값과 다른 색상·레이아웃의 정적 텍스트로 그립니다. 대신 값 전체를 한 번에 선택하거나 수정할 수 없습니다.
전화번호·이메일, 카드 유효기간(MM / YY)처럼 가릴 필요가 없는 일반 정보는 Text Field에 formatter를 적용해 받으세요(Number Formatting 참고). 형식이 정해져 있다는 이유만으로 입력을 여러 칸으로 나누지 않습니다.
Segmented Text Input은 Text Field와 formatter로 같은 표현을 손실 없이 만들 수 없을 때 고릅니다. 주민등록번호·국내 카드번호처럼 값의 일부만 가려야 하거나, 구분자와 입력값을 다른 색상·레이아웃으로 보여야 하는 경우가 여기에 해당합니다. 가리기 여부만으로 정해지지는 않으니, Text Field로 충분한지 먼저 확인하세요.
주민등록번호와 카드번호는 snippet으로 제공합니다. 자리 구성과 문구(aria-label, placeholder)는 snippet에 있으니 필요하면 설치한 파일에서 고쳐 쓰세요. 두 snippet은 label·description·errorMessage를 그리는 segmented-text-field snippet 위에 만들어져 있어 함께 설치됩니다.
npx @seed-design/cli@latest add ui:resident-number-field pnpm dlx @seed-design/cli@latest add ui:resident-number-field yarn dlx @seed-design/cli@latest add ui:resident-number-field bun x @seed-design/cli@latest add ui:resident-number-field
아래 코드를 복사 후 붙여넣고 사용하세요 /**
* @file ui:resident-number-field
* @requires @seed-design/react@^3.1.0
**/
// Note for AI Agents: Only the shipped formats (ResidentNumberField, CardNumberField) are supported; if you need to compose SegmentedTextField or SegmentedTextInput into any other format, stop and tell the user before writing code.
"use client" ;
import * as React from "react" ;
import {
SegmentedTextField,
SegmentedTextFieldDots,
SegmentedTextFieldSegment,
SegmentedTextFieldSeparator,
type SegmentedTextFieldProps,
} from "./segmented-text-field" ;
export interface ResidentNumberFieldProps extends Omit < SegmentedTextFieldProps , "children" > {
/**
* 입력받을 자리 수.
* - `6`: 생년월일 6자리만 입력받고 뒷자리는 점으로 표시합니다.
* - `7`: 생년월일 6자리와 뒷자리 첫 번째 자리를 입력받습니다.
* - `13`: 생년월일 6자리와 뒷자리 7자리를 입력받습니다. 뒷자리는 가려서 표시합니다.
* @default 7
*/
digits ?: 6 | 7 | 13 ;
}
/**
* 값은 입력 Segment 순서대로 `string[]`입니다. `digits=6`이면 `[생년월일]`, 그 밖에는 `[생년월일, 뒷자리]`입니다.
*
* @see https://seed-design.io/react/components/segmented-text-input
*/
export const ResidentNumberField = React. forwardRef < HTMLDivElement , ResidentNumberFieldProps >(
({ digits = 7 , ... otherProps }, ref ) => {
return (
< SegmentedTextField ref = {ref} { ... otherProps}>
< SegmentedTextFieldSegment
aria-label = "생년월일 6자리"
placeholder = "생년월일 6자리"
length = { 6 }
autoComplete = "bday"
sizing = "fill"
/>
< SegmentedTextFieldSeparator > - </ SegmentedTextFieldSeparator >
{digits === 6 && (
< SegmentedTextFieldSegment
staticText = {< SegmentedTextFieldDots count = { 7 } />}
sizing = "fill"
align = "center"
/>
)}
{digits === 7 && (
< SegmentedTextFieldSegment
aria-label = "주민등록번호 뒷자리 첫 번째 자리"
placeholder = {< SegmentedTextFieldDots count = { 1 } />}
length = { 1 }
staticSuffix = {< SegmentedTextFieldDots count = { 6 } />}
sizing = "fill"
align = "center"
/>
)}
{digits === 13 && (
< SegmentedTextFieldSegment
aria-label = "주민등록번호 뒷자리 7자리"
length = { 7 }
masked
sizing = "fill"
align = "center"
/>
)}
</ SegmentedTextField >
);
},
);
ResidentNumberField.displayName = "ResidentNumberField" ;
/**
* This file is a snippet from SEED Design, helping you get started quickly with @seed-design/* packages.
* You can extend this snippet however you want.
*/
npx @seed-design/cli@latest add ui:card-number-field pnpm dlx @seed-design/cli@latest add ui:card-number-field yarn dlx @seed-design/cli@latest add ui:card-number-field bun x @seed-design/cli@latest add ui:card-number-field
아래 코드를 복사 후 붙여넣고 사용하세요 /**
* @file ui:card-number-field
* @requires @seed-design/react@^3.1.0
**/
// Note for AI Agents: Only the shipped formats (ResidentNumberField, CardNumberField) are supported; if you need to compose SegmentedTextField or SegmentedTextInput into any other format, stop and tell the user before writing code.
"use client" ;
import * as React from "react" ;
import {
SegmentedTextField,
SegmentedTextFieldSegment,
SegmentedTextFieldSeparator,
SegmentedTextFieldSuffix,
type SegmentedTextFieldProps,
} from "./segmented-text-field" ;
const SEGMENT_LABELS = [
"카드번호 1-4번째 자리" ,
"카드번호 5-8번째 자리" ,
"카드번호 9-12번째 자리" ,
"카드번호 13-16번째 자리" ,
];
export interface CardNumberFieldProps extends Omit < SegmentedTextFieldProps , "children" > {
/**
* 가려서 입력받을 뒷자리 수. 가린 Segment는 붙여넣기·자동완성으로 채우지 않습니다.
* @default 8
*/
maskedDigits ?: 4 | 8 ;
/**
* 입력 끝에 표시할 요소. 카드사 로고 등을 넣습니다.
*/
suffix ?: React . ReactNode ;
}
/**
* 4자리씩 네 Segment로 카드번호를 입력받습니다. 값은 `string[]`(4개)입니다.
*
* @see https://seed-design.io/react/components/segmented-text-input
*/
export const CardNumberField = React. forwardRef < HTMLDivElement , CardNumberFieldProps >(
({ maskedDigits = 8 , suffix , ... otherProps }, ref ) => {
const maskedFrom = SEGMENT_LABELS . length - maskedDigits / 4 ;
return (
< SegmentedTextField ref = {ref} { ... otherProps}>
{ SEGMENT_LABELS . map (( label , i ) => {
const masked = i >= maskedFrom;
return (
< React.Fragment key = {label}>
{i > 0 && < SegmentedTextFieldSeparator > - </ SegmentedTextFieldSeparator >}
< SegmentedTextFieldSegment
aria-label = {label}
placeholder = {masked ? undefined : "0000" }
length = { 4 }
masked = {masked}
autoComplete = {i === 0 ? "cc-number" : masked ? "off" : undefined }
/>
</ React.Fragment >
);
})}
{suffix && < SegmentedTextFieldSuffix >{suffix}</ SegmentedTextFieldSuffix >}
</ SegmentedTextField >
);
},
);
CardNumberField.displayName = "CardNumberField" ;
/**
* This file is a snippet from SEED Design, helping you get started quickly with @seed-design/* packages.
* You can extend this snippet however you want.
*/
defaultValue?string[] | undefined
onValueChange?(( value : string []) => void ) | undefined
label?React.ReactNode
indicator?React.ReactNode
description?React.ReactNode
errorMessage?React.ReactNode
showRequiredIndicator?boolean | undefined
fieldRef?React.Ref < HTMLDivElement > | undefined
defaultValue?string[] | undefined
onValueChange?(( value : string []) => void ) | undefined
label?React.ReactNode
indicator?React.ReactNode
description?React.ReactNode
errorMessage?React.ReactNode
showRequiredIndicator?boolean | undefined
fieldRef?React.Ref < HTMLDivElement > | undefined
label?React.ReactNode
labelWeight?"medium" | "bold" | undefined
indicator?React.ReactNode
description?React.ReactNode
errorMessage?React.ReactNode
showRequiredIndicator?boolean | undefined
fieldRef?React.Ref < HTMLDivElement > | undefined
size?"large" | "medium" | undefined
defaultValue?string[] | undefined
value?string[] | undefined
onValueChange?(( value : string []) => void ) | undefined
disabled?boolean | undefined
readOnly?boolean | undefined
invalid?boolean | undefined
required?boolean | undefined
children에 넣는 컴포넌트는 @seed-design/react의 컴포넌트를 그대로 내보낸 것입니다.
defaultValue?string[] | undefined
onValueChange?(( value : string []) => void ) | undefined
staticText를 주면 입력 없이 정적 텍스트만 표시하는 Segment가 됩니다. 정적 Segment는 값 배열에 포함하지 않습니다.
주민등록번호 뒷자리처럼 가린 자리를 7×7 점으로 표시합니다. staticText·staticSuffix·placeholder에 넣습니다.
< SegmentedTextInput.Segment
aria-label = "주민등록번호 뒷자리 첫 번째 자리"
length = { 1 }
placeholder = {< SegmentedTextInput.Dots count = { 1 } />}
staticSuffix = {< SegmentedTextInput.Dots count = { 6 } />}
/>
digits로 입력받을 자리 수를 정합니다. (default: 7)
import { VStack } from "@seed-design/react" ;
import { ResidentNumberField } from "seed-design/ui/resident-number-field" ;
export default function SegmentedTextInputResidentNumberDigits () {
return (
< VStack width = "358px" maxWidth = "100%" gap = "spacingY.componentDefault" >
< ResidentNumberField label = "주민등록번호" description = "digits=6" digits = { 6 } />
< ResidentNumberField label = "주민등록번호" description = "digits=7 (default)" digits = { 7 } />
< ResidentNumberField label = "주민등록번호" description = "digits=13" digits = { 13 } />
</ VStack >
);
}
maskedDigits로 가릴 뒷자리 수를 정하고, suffix에 카드사 로고를 넣습니다. 로고 크기는 넣는 쪽에서 정합니다(Figma 기준 30×20).
import { VStack } from "@seed-design/react" ;
import { CardNumberField } from "seed-design/ui/card-number-field" ;
export default function SegmentedTextInputCardNumber () {
return (
< VStack width = "358px" maxWidth = "100%" gap = "spacingY.componentDefault" >
< CardNumberField label = "카드번호" description = "maskedDigits=8 (default)" />
< CardNumberField
label = "카드번호"
description = "maskedDigits=4, suffix"
maskedDigits = { 4 }
suffix = {< CardLogo />}
/>
</ VStack >
);
}
// 예시용 카드사 로고입니다. 실제 서비스에서는 카드사 로고 자산을 크기와 함께 넣습니다.
function CardLogo () {
return (
< svg width = "30" height = "20" viewBox = "0 0 30 20" role = "img" aria-label = "카드사" >
< rect width = "30" height = "20" rx = "4" fill = "#00a05b" />
< rect x = "5" y = "7" width = "8" height = "6" rx = "1.5" fill = "#ffffff" fillOpacity = "0.85" />
</ svg >
);
}
import { VStack } from "@seed-design/react" ;
import { ResidentNumberField } from "seed-design/ui/resident-number-field" ;
export default function SegmentedTextInputState () {
return (
< VStack width = "358px" maxWidth = "100%" gap = "spacingY.componentDefault" >
< ResidentNumberField label = "Enabled" description = "설명을 써주세요" />
< ResidentNumberField
label = "Invalid"
defaultValue = {[ "931305" , "1" ]}
invalid
errorMessage = "생년월일을 다시 확인해주세요"
/>
< ResidentNumberField label = "Disabled" defaultValue = {[ "931205" , "1" ]} disabled />
< ResidentNumberField label = "Read Only" defaultValue = {[ "931205" , "1" ]} readOnly />
</ VStack >
);
}
medium은 데스크톱(Breakpoint lg 이상)에서만 사용합니다. (default: large)
import { VStack } from "@seed-design/react" ;
import { CardNumberField } from "seed-design/ui/card-number-field" ;
export default function SegmentedTextInputSize () {
return (
< VStack width = "358px" maxWidth = "100%" gap = "spacingY.componentDefault" >
< CardNumberField label = "카드번호" description = "size=large (default)" size = "large" />
< CardNumberField label = "카드번호" description = "size=medium" size = "medium" />
</ VStack >
);
}
import { useState } from "react" ;
import { Text, VStack } from "@seed-design/react" ;
import { ResidentNumberField } from "seed-design/ui/resident-number-field" ;
export default function SegmentedTextInputControlled () {
const [ value , setValue ] = useState < string []>([ "" , "" ]);
return (
< VStack width = "358px" maxWidth = "100%" gap = "x3" >
< ResidentNumberField label = "주민등록번호" value = {value} onValueChange = {setValue} />
< Text textStyle = "t4Regular" color = "fg.neutralMuted" >
value: { JSON . stringify (value)}
</ Text >
</ VStack >
);
}
useController의 value·onChange를 value·onValueChange에 연결합니다. 값은 Segment별 string[]이므로 validate에서 Segment마다 자리 수를 확인합니다.
import { HStack, VStack } from "@seed-design/react" ;
import { useCallback, type FormEvent } from "react" ;
import { useController, useForm } from "react-hook-form" ;
import { ActionButton } from "seed-design/ui/action-button" ;
import { CardNumberField } from "seed-design/ui/card-number-field" ;
import { ResidentNumberField } from "seed-design/ui/resident-number-field" ;
interface FormValues {
residentNumber : string [];
cardNumber : string [];
}
export default function SegmentedTextInputReactHookForm () {
const { handleSubmit , reset , control } = useForm < FormValues >({
reValidateMode: "onSubmit" ,
defaultValues: {
residentNumber: [ "" , "" ],
cardNumber: [ "" , "" , "" , "" ],
},
});
const { field : residentNumberField , fieldState : residentNumberFieldState } = useController ({
name: "residentNumber" ,
control,
rules: {
validate : ([ birth = "" , back = "" ]) =>
(birth. length === 6 && back. length === 1 ) || "주민등록번호 앞 7자리를 모두 입력해주세요" ,
},
});
const { field : cardNumberField , fieldState : cardNumberFieldState } = useController ({
name: "cardNumber" ,
control,
rules: {
validate : ( value ) =>
value. every (( segment ) => segment. length === 4 ) || "카드번호 16자리를 모두 입력해주세요" ,
},
});
const onValid = useCallback (( data : FormValues ) => {
window. alert ( JSON . stringify (data, null , 2 ));
}, []);
const onReset = useCallback (
( event : FormEvent ) => {
event. preventDefault ();
reset ();
},
[reset],
);
return (
< VStack
gap = "x3"
width = "358px"
maxWidth = "100%"
as = "form"
onSubmit = { handleSubmit (onValid)}
onReset = {onReset}
>
< ResidentNumberField
label = "주민등록번호"
invalid = {residentNumberFieldState.invalid}
errorMessage = {residentNumberFieldState.error?.message}
value = {residentNumberField.value}
onValueChange = {residentNumberField.onChange}
onBlur = {residentNumberField.onBlur}
required
showRequiredIndicator
/>
< CardNumberField
label = "카드번호"
invalid = {cardNumberFieldState.invalid}
errorMessage = {cardNumberFieldState.error?.message}
value = {cardNumberField.value}
onValueChange = {cardNumberField.onChange}
onBlur = {cardNumberField.onBlur}
required
showRequiredIndicator
/>
< HStack gap = "x2" >
< ActionButton type = "reset" variant = "neutralWeak" >
초기화
</ ActionButton >
< ActionButton type = "submit" variant = "neutralSolid" flexGrow = { 1 }>
제출
</ ActionButton >
</ HStack >
</ VStack >
);
}
보안 키패드처럼 값을 밖에서 채워야 하면 해당 Segment를 readOnly로 두고 값을 controlled로 관리합니다. Segment를 누르면 키패드를 열고, 키패드가 value를 바꿉니다. 키패드 연동과 값 암호화는 서비스에서 구현합니다.
import { Fragment, useState } from "react" ;
import { HStack, VStack } from "@seed-design/react" ;
import { ActionButton } from "seed-design/ui/action-button" ;
import {
SegmentedTextField,
SegmentedTextFieldSegment,
SegmentedTextFieldSeparator,
} from "seed-design/ui/segmented-text-field" ;
const LABELS = [ "1-4번째 자리" , "5-8번째 자리" , "9-12번째 자리" , "13-16번째 자리" ];
const KEYPAD_SEGMENTS = [ 2 , 3 ];
const KEYS = [ "1" , "2" , "3" , "4" , "5" , "6" , "7" , "8" , "9" , "0" ];
export default function SegmentedTextInputExternalKeypad () {
const [ value , setValue ] = useState ([ "" , "" , "" , "" ]);
const [ keypadOpen , setKeypadOpen ] = useState ( false );
const press = ( key : string ) =>
setValue (( prev ) => {
const index = KEYPAD_SEGMENTS . find (( i ) => prev[i]. length < 4 );
if (index === undefined ) return prev;
return prev. map (( segment , i ) => (i === index ? segment + key : segment));
});
const erase = () =>
setValue (( prev ) => {
const index = KEYPAD_SEGMENTS . findLast (( i ) => prev[i]. length > 0 );
if (index === undefined ) return prev;
return prev. map (( segment , i ) => (i === index ? segment. slice ( 0 , - 1 ) : segment));
});
return (
< VStack width = "358px" maxWidth = "100%" gap = "x4" >
< SegmentedTextField label = "카드번호" value = {value} onValueChange = {setValue}>
{ LABELS . map (( label , i ) => {
const usesKeypad = KEYPAD_SEGMENTS . includes (i);
return (
< Fragment key = {label}>
{i > 0 && < SegmentedTextFieldSeparator > - </ SegmentedTextFieldSeparator >}
< SegmentedTextFieldSegment
aria-label = { `카드번호 ${ label }` }
placeholder = {usesKeypad ? undefined : "0000" }
length = { 4 }
masked = {usesKeypad}
// 직접 입력을 막고 탭하면 외부 키패드를 연다.
readOnly = {usesKeypad}
inputMode = {usesKeypad ? "none" : "numeric" }
onFocus = {() => setKeypadOpen (usesKeypad)}
/>
</ Fragment >
);
})}
</ SegmentedTextField >
{keypadOpen && (
// 키패드를 눌러도 입력의 focus가 유지되도록 mousedown 기본 동작을 막는다.
< HStack wrap gap = "x2" onMouseDown = {( event ) => event. preventDefault ()}>
{ KEYS . map (( key ) => (
< ActionButton key = {key} variant = "neutralWeak" onClick = {() => press (key)}>
{key}
</ ActionButton >
))}
< ActionButton variant = "neutralWeak" onClick = {erase}>
지우기
</ ActionButton >
</ HStack >
)}
</ VStack >
);
}