Featuring Design System
Components

Field

폼 컨트롤에 레이블·보조 텍스트·검증 메시지를 연결하는 컨테이너 컴포넌트.

개요

Field는 TextInput, TextArea, Checkbox, Radio 등 폼 컨트롤을 감싸는 레이아웃 + 접근성 크롬 컴포넌트입니다. Field.Root, Field.Label, Field.Description, Field.Message, Field.Count 다섯 서브컴포넌트로 구성됩니다.

  • 자동 ID 연결Field.Root가 고유 fieldId, descriptionId, validationId를 생성하고 FieldContext로 자식에 전파
  • 레이블 연결Field.LabelhtmlForfieldId에 자동 설정
  • 보조 텍스트 연결Field.DescriptioniddescriptionId에 자동 설정. 폼 컨트롤의 aria-describedby에 연결됨
  • 검증 메시지Field.Messagestatusnone이 아닐 때만 렌더링. error는 role="alert", 그 외는 aria-live="polite"로 자동 안내
  • 상태 전파status, disabled, readOnly, required가 FieldContext를 통해 하위 서브컴포넌트로 전달
  • 4가지 statusnone (기본), success, warning, error
  • requiredField.Root에 지정하면 Field.Label* 인디케이터가 자동 표시되고 폼 컨트롤에 aria-required가 자동 부여

언제 사용하나요

  • 텍스트 입력, 드롭다운 등 폼 컨트롤에 레이블을 붙일 때
  • 입력값 검증 결과(에러, 성공, 경고)를 사용자에게 알릴 때
  • 입력 가이드나 제약 조건을 보조 텍스트로 안내할 때
  • 글자 수 카운터처럼 레이블 영역에 부가 정보를 배치할 때

언제 사용하면 안 되나요

  • 폼 컨트롤 없이 텍스트만 표시할 때 → Typo 사용
  • 페이지 단위 안내 메시지 → SectionMessage 사용
  • 버튼 그룹에 제목을 붙일 때 → <fieldset> + <legend> 직접 사용

접근성

키보드 동작

Field는 포커스를 받지 않습니다. 내부 폼 컨트롤의 키보드 동작을 따릅니다.

스크린리더 동작

  • Field.LabelhtmlFor가 폼 컨트롤 id에 연결되어 레이블을 읽음
  • Field.Descriptionid가 폼 컨트롤의 aria-describedby에 연결되어 보조 텍스트를 추가로 읽음
  • Field.Messagestatus="error"일 때 role="alert"로 즉시 안내, 그 외에는 aria-live="polite"로 부드럽게 안내
  • Field.Rootrequired를 지정하면 폼 컨트롤이 aria-required="true"를 자동으로 받음
  • disabled 상태에서는 Field.Message가 렌더링되지 않아 불필요한 안내를 방지

WCAG 기준

기준수준적용
1.3.1 Info and RelationshipsAhtmlFor로 레이블과 입력 요소 연결
3.3.1 Error IdentificationAField.Message로 오류 항목과 내용 명시
3.3.2 Labels or InstructionsAField.Description으로 입력 형식 안내
3.3.7 Redundant EntryArequired context를 폼 컨트롤에 자동 전파 (aria-required)
4.1.3 Status MessagesAAerror는 role="alert", 그 외는 aria-live="polite"로 자동 안내

Usage

기본 사용법

Field.Root로 폼 컨트롤을 감싸고 Field.LabelField.Description을 배치합니다. ID 연결은 자동으로 처리됩니다.

() => {
const [value, setValue] = React.useState('');
return (
  <Field.Root $css={{ width: '320px' }}>
    <Field.Label>이름</Field.Label>
    <TextInput.Root>
      <TextInput.Input
        placeholder="이름을 입력하세요"
        value={value}
        onChange={e => setValue(e.target.value)}
      />
    </TextInput.Root>
    <Field.Description>실명을 입력해 주세요.</Field.Description>
  </Field.Root>
);
}

검증 상태

status prop으로 네 가지 상태를 표현합니다. Field.Messagestatusnone이 아닐 때만 화면에 나타납니다.

<VStack $css={{ gap: '$spacing-500', alignItems: 'flex-start' }}>
<Field.Root status="success" $css={{ width: '320px' }}>
  <Field.Label>아이디</Field.Label>
  <TextInput.Root>
    <TextInput.Input defaultValue="featuring_user" />
  </TextInput.Root>
  <Field.Message>사용 가능한 아이디입니다.</Field.Message>
</Field.Root>
<Field.Root status="warning" $css={{ width: '320px' }}>
  <Field.Label>비밀번호</Field.Label>
  <TextInput.Root>
    <TextInput.Input type="password" defaultValue="1234" />
  </TextInput.Root>
  <Field.Message>비밀번호가 너무 짧습니다.</Field.Message>
</Field.Root>
<Field.Root status="error" $css={{ width: '320px' }}>
  <Field.Label>이메일</Field.Label>
  <TextInput.Root>
    <TextInput.Input defaultValue="not-an-email" />
  </TextInput.Root>
  <Field.Message>올바른 이메일 형식이 아닙니다.</Field.Message>
</Field.Root>
</VStack>

동적 검증

입력 중 실시간으로 status를 변경하면 Field.Messagearia-live="polite"로 자동 안내합니다.

() => {
const [value, setValue] = React.useState('');
const isError = value.length > 0 && value.length < 8;
return (
  <Field.Root status={isError ? 'error' : 'none'} $css={{ width: '320px' }}>
    <Field.Label>비밀번호</Field.Label>
    <TextInput.Root>
      <TextInput.Input
        type="password"
        placeholder="8자 이상 입력하세요"
        value={value}
        onChange={e => setValue(e.target.value)}
      />
    </TextInput.Root>
    <Field.Description>영문, 숫자를 포함해 8자 이상 입력하세요.</Field.Description>
    <Field.Message>비밀번호는 8자 이상이어야 합니다.</Field.Message>
  </Field.Root>
);
}

필수 항목

Field.Rootrequired를 지정하면 Field.Label* 인디케이터가 자동으로 표시되고, 폼 컨트롤에는 aria-required="true"가 자동 부여됩니다. Field.Label에 직접 required를 줘도 동일하게 동작합니다.

<VStack $css={{ gap: '$spacing-400', alignItems: 'flex-start' }}>
<Field.Root required $css={{ width: '320px' }}>
  <Field.Label>이메일</Field.Label>
  <TextInput.Root>
    <TextInput.Input type="email" placeholder="example@email.com" />
  </TextInput.Root>
  <Field.Description>Field.Root에 required — Label과 input 모두 자동 동기화</Field.Description>
</Field.Root>
<Field.Root $css={{ width: '320px' }}>
  <Field.Label required>이름</Field.Label>
  <TextInput.Root>
    <TextInput.Input placeholder="이름을 입력하세요" />
  </TextInput.Root>
  <Field.Description>Field.Label에 직접 required도 가능 (Root context 없이)</Field.Description>
</Field.Root>
</VStack>

Description과 Message 구분

Field.Description은 상태와 무관하게 항상 표시됩니다. Field.Messagestatus가 활성(success, warning, error)일 때만 나타나며, disabledreadOnly 상태에서도 숨겨집니다.

() => {
const [hasError, setHasError] = React.useState(false);
return (
  <Field.Root status={hasError ? 'error' : 'none'} $css={{ width: '320px' }}>
    <Field.Label>비밀번호</Field.Label>
    <TextInput.Root>
      <TextInput.Input
        type="password"
        placeholder="8자 이상 입력하세요"
        onChange={(e) => setHasError(e.target.value.length > 0 && e.target.value.length < 8)}
      />
    </TextInput.Root>
    <Field.Description>영문, 숫자를 포함해 8자 이상 입력하세요.</Field.Description>
    <Field.Message>비밀번호는 8자 이상이어야 합니다.</Field.Message>
  </Field.Root>
);
}

비활성 상태

Field.Rootdisabled를 설정하면 FieldContext를 통해 하위 서브컴포넌트 전체에 전파됩니다. Field.Message는 비활성 상태에서 렌더링되지 않습니다.

<Field.Root disabled $css={{ width: '320px' }}>
<Field.Label>사용자 ID</Field.Label>
<TextInput.Root>
  <TextInput.Input value="ft_user_001" readOnly />
</TextInput.Root>
<Field.Description>수정할 수 없는 항목입니다.</Field.Description>
</Field.Root>

읽기 전용

<Field.Root readOnly $css={{ width: '320px' }}>
<Field.Label>가입일</Field.Label>
<TextInput.Root>
  <TextInput.Input defaultValue="2024-01-15" />
</TextInput.Root>
<Field.Description>가입일은 변경할 수 없습니다.</Field.Description>
</Field.Root>

Field.Count — 글자 수 카운터

Field.Count는 글자 수를 표시하는 서브컴포넌트입니다. 일반적으로 HStack으로 Field.Label과 같은 행에 배치합니다.

() => {
const [value, setValue] = React.useState('');
const maxLength = 200;
return (
  <Field.Root $css={{ width: '320px' }}>
    <HStack $css={{ justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
      <Field.Label>소개</Field.Label>
      <Field.Count>{value.length}/{maxLength}</Field.Count>
    </HStack>
    <TextArea.Root>
      <TextArea.Input
        placeholder="간단한 소개를 입력하세요"
        maxLength={maxLength}
        value={value}
        onChange={(e) => setValue(e.target.value)}
      />
    </TextArea.Root>
    <Field.Description>최대 {maxLength}자까지 입력할 수 있습니다.</Field.Description>
  </Field.Root>
);
}

DatePicker와 함께

Calendar의 DatePicker 트리거를 Field에 배치합니다. 클릭 전용은 Dropdown.Trigger, 타이핑 가능은 TextInput을 사용합니다.

() => {
const [date1, setDate1] = React.useState(null);
const [date2, setDate2] = React.useState(null);
return (
  <VStack $css={{ gap: '$spacing-600', alignItems: 'flex-start' }}>
    <Field.Root>
      <Field.Label required>시작일 (클릭 전용)</Field.Label>
      <Calendar.Root inline={false} selected={date1} onChange={(d) => setDate1(d)} dateFormat="yyyy.MM.dd">
        <Calendar.Trigger>
          {(triggerProps) => (
            <Dropdown.Trigger size={triggerProps.size} $css={{ width: '240px' }}>
              <Dropdown.Icon><IconCalendarFilled /></Dropdown.Icon>
              <Dropdown.Value placeholder="날짜 선택">{triggerProps.value}</Dropdown.Value>
              <Dropdown.Indicator data-rotate />
            </Dropdown.Trigger>
          )}
        </Calendar.Trigger>
      </Calendar.Root>
      <Field.Description>프로젝트 시작 날짜를 선택하세요</Field.Description>
    </Field.Root>
    <Field.Root>
      <Field.Label required>시작일 (타이핑)</Field.Label>
      <Calendar.Root inline={false} selected={date2} onChange={(d) => setDate2(d)} dateFormat="yyyy.MM.dd">
        <Calendar.Trigger>
          {(triggerProps) => (
            <TextInput.Root size={triggerProps.size} $css={{ width: '240px' }}>
              <TextInput.Icon><IconCalendarFilled /></TextInput.Icon>
              <TextInput.Input {...triggerProps} placeholder="yyyy.MM.dd" />
              <TextInput.Icon data-rotate $css={{ color: '$icon-secondary' }}>
                <IconChevronDownOutline />
              </TextInput.Icon>
            </TextInput.Root>
          )}
        </Calendar.Trigger>
      </Calendar.Root>
      <Field.Description>날짜를 직접 입력하거나 캘린더에서 선택하세요</Field.Description>
    </Field.Root>
  </VStack>
);
}

DatePicker (타이핑 가능)

Calendar.Trigger + TextInput.Root 조합으로 직접 입력 + 캘린더 선택을 모두 허용합니다.

() => {
const [date, setDate] = React.useState(null);
return (
  <Field.Root>
    <Field.Label required>시작일</Field.Label>
    <Calendar.Root inline={false} selected={date} onChange={(d) => setDate(d)} dateFormat="yyyy.MM.dd">
      <Calendar.Trigger>
        {(triggerProps) => (
          <TextInput.Root size={triggerProps.size} $css={{ width: '240px' }}>
            <TextInput.Icon><IconCalendarFilled /></TextInput.Icon>
            <TextInput.Input {...triggerProps} placeholder="yyyy.MM.dd" />
            <TextInput.Icon data-rotate $css={{ color: '$icon-secondary' }}>
              <IconChevronDownOutline />
            </TextInput.Icon>
          </TextInput.Root>
        )}
      </Calendar.Trigger>
    </Calendar.Root>
    <Field.Description>날짜를 직접 입력하거나 캘린더에서 선택하세요</Field.Description>
  </Field.Root>
);
}

TextArea와 함께

TextAreaField.Count로 글자수 카운터 패턴.

() => {
const [value, setValue] = React.useState('');
const maxLength = 200;
return (
  <Field.Root $css={{ width: '320px' }}>
    <HStack $css={{ justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
      <Field.Label>소개</Field.Label>
      <Field.Count>{value.length}/{maxLength}</Field.Count>
    </HStack>
    <TextArea.Root>
      <TextArea.Input
        placeholder="간단한 소개를 입력하세요"
        maxLength={maxLength}
        value={value}
        onChange={(e) => setValue(e.currentTarget.value)}
      />
    </TextArea.Root>
    <Field.Description>Field.Count를 사용한 글자수 카운터 예시입니다.</Field.Description>
  </Field.Root>
);
}

Select와 함께

SelectField에 배치하면 status, disabled가 자동 전파됩니다.

<Field.Root>
<Field.Label>카테고리</Field.Label>
<Select.Root>
  <Select.Trigger $css={{ width: '240px' }}>
    <Select.Value placeholder="선택하세요" />
  </Select.Trigger>
  <Select.Portal>
    <Select.Positioner>
      <Select.Popup>
        <Select.Item value="design">디자인</Select.Item>
        <Select.Item value="develop">개발</Select.Item>
        <Select.Item value="marketing">마케팅</Select.Item>
      </Select.Popup>
    </Select.Positioner>
  </Select.Portal>
</Select.Root>
<Field.Description>프로젝트 카테고리를 선택하세요</Field.Description>
</Field.Root>

Checkbox와 함께

Field는 TextInput 외에 Checkbox, Radio 등과도 함께 사용할 수 있습니다.

<Field.Root $css={{ width: '320px' }}>
<Field.Label>약관 동의</Field.Label>
<Label.Root>
  <Checkbox />
  <Label.Text>이용약관에 동의합니다</Label.Text>
</Label.Root>
<Field.Description>서비스 이용을 위해 약관 동의가 필요합니다.</Field.Description>
</Field.Root>

커스텀 레이아웃

$css로 레이아웃을 자유롭게 조정합니다. 여러 필드를 나란히 배치할 때는 HStack을 활용합니다.

<HStack $css={{ gap: '$spacing-400', alignItems: 'flex-start', width: '480px' }}>
<Field.Root $css={{ flex: '1' }}>
  <Field.Label></Field.Label>
  <TextInput.Root>
    <TextInput.Input placeholder="" />
  </TextInput.Root>
</Field.Root>
<Field.Root $css={{ flex: '2' }}>
  <Field.Label>이름</Field.Label>
  <TextInput.Root>
    <TextInput.Input placeholder="길동" />
  </TextInput.Root>
</Field.Root>
</HStack>

Ellipsis Overflow (소비자 opt-in)

Field.Label / Field.Description / Field.Message는 기본 wrap입니다. 좁은 컬럼 레이아웃에서 한 줄 + ellipsis가 필요하면 $css로 opt-in합니다.

<Field.Root $css={{ width: '200px' }}>
<Field.Label $css={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
  매우 긴 레이블 — 데이터 보존 동의
</Field.Label>
<TextInput.Root>
  <TextInput.Input placeholder="선택" />
</TextInput.Root>
<Field.Description $css={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
  개인정보 처리방침에 따라 사용자 데이터는 90일간 보관됩니다
</Field.Description>
</Field.Root>

$css 커스텀

<Field.Root $css={{ width: '320px', gap: '$spacing-200' }}>
<Field.Label>메모</Field.Label>
<TextInput.Root>
  <TextInput.Input placeholder="메모를 입력하세요" />
</TextInput.Root>
<Field.Description>내부 참고용 메모입니다.</Field.Description>
</Field.Root>

Props

공통 Props$css, render, className, style는 모든 서브컴포넌트에서 지원됩니다. useRenderComponent 가이드 →

Field.Root

폼 필드의 최상위 컨테이너입니다. 내부적으로 useId()fieldId, descriptionId, validationId를 생성하고 FieldContext로 자식에 전파합니다.

Prop

Type

Field.Label

레이블 요소입니다. htmlForField.RootfieldId에 자동 연결됩니다. Field.Rootrequired가 지정되면 자동으로 * 인디케이터를 표시합니다 — 직접 prop으로 override 가능.

Prop

Type

Field.Description

항상 표시되는 보조 설명 텍스트입니다. id가 자동 설정되어 폼 컨트롤의 aria-describedby에 연결됩니다.

Prop

Type

Field.Message

검증 메시지입니다. statusnone이 아니고, disabled·readOnly가 아닌 상태에서 children이 있을 때만 렌더링됩니다. status="error"이면 role="alert"로 즉시 안내, 그 외에는 aria-live="polite"로 부드럽게 안내합니다.

Prop

Type

Field.Count

글자 수 카운터입니다. 일반적으로 Field.Label과 같은 행(HStack)에 배치합니다. 카운터 텍스트를 children으로 직접 전달합니다.

Prop

Type

스타일

Status Variants

StatusMessage 색상아이콘
none없음 (Message 자체가 렌더링 안 됨)
successsupport-success-1CheckCircle
warningsupport-warning-1WarningCircle
errorsupport-error-1XCircle

FieldState

className, style 콜백에 전달되는 상태 객체입니다. Root, Label, Description, Message, Count 모두 동일한 상태를 받습니다.

Prop

Type