Form control

입력 필드에 Label과 Message를 붙여 무엇을 입력해야 하는지 안내하는 컴포넌트입니다. Text field, Text area, Select와 같은 입력 필드를 감싸서 사용하며, 접근성 연결을 처리합니다.

Introduce

form 구조 및 접근성 처리를 다룹니다. 각 컴포넌트별 활용법은 TextField, TextArea, Checkbox, Radio 등을 참고해주세요.

이 문서는 react-hook-form과 함께 활용하는 예시를 소개합니다.

값을 입력해주세요.

import { Button, FlexBox, TextField, FormControl, FormControlField, FormControlLabel, FormControlMessage, FormControlNegativeMessage } from '@montage-ui/core';
import { useForm, Controller } from 'react-hook-form';

const Demo = () => {
  const form = useForm({
    defaultValues: {
      content: ''
    }
  });

  return (
    <FlexBox as="form" flexDirection="column" gap="12px" onSubmit={form.handleSubmit(v => alert(JSON.stringify(v)))}>
      <Controller
        control={form.control}
        name="content"
        rules={{
          required: {
            value: true,
            message: '필수 값입니다.'
          }
        }}
        render={({ field, formState }) => (
          <FormControl>
            <FormControlLabel required>라벨</FormControlLabel>

            <FormControlField>
              <TextField {...field} width="25ch" placeholder="값을 입력하세요." status={Boolean(formState.errors.content) ? 'negative' : 'normal'} />
            </FormControlField>

            {Boolean(formState.errors.content) ? (
              <FormControlNegativeMessage>
                {formState.errors.content?.message}
              </FormControlNegativeMessage>
            ) : (
             <FormControlMessage>값을 입력해주세요.</FormControlMessage>
            )}
          </FormControl>
        )}
      />

      <Button type="submit">제출</Button>
    </FlexBox>
  )
}

export default Demo;

Anatomy

FormControl 컴포넌트는 아래와 같은 구조를 가집니다.

<form onSubmit={handleSubmit}>
  <FormControl>
    <FormControlLabel>label</FormControlLabel>
    <FormControlField>
      <input {...field} />
    </FormControlField>

    {status === 'normal' && <FormControlMessage>설명</FormControlMessage>}
    {status === 'negative' && <FormControlNegativeMessage>오류 메시지</FormControlNegativeMessage>}
    {status === 'positive' && <FormControlPositiveMessage>성공 메시지</FormControlPositiveMessage>}
  </FormControl>

  <button type="submit" />
</form>

Label placement

labelPlacement 를 조절하여 라벨의 위치를 조절할 수 있습니다.

  • top (default)
  • leading

값을 입력해주세요.

값을 입력해주세요.

import { Button, FlexBox, TextField, FormControl, FormControlField, FormControlLabel, FormControlMessage, FormControlNegativeMessage, FormControlMessageAccessory } from '@montage-ui/core';
import { useForm, Controller } from 'react-hook-form';

const Demo = () => {
  const form = useForm({
    defaultValues: {
      top: '',
      leading: ''
    }
  });

  return (
    <FlexBox as="form" flexDirection="column" gap="12px" onSubmit={form.handleSubmit(v => alert(JSON.stringify(v)))}>
      <Controller
        control={form.control}
        name="top"
        rules={{
          required: {
            value: true,
            message: '필수 값입니다.'
          }
        }}
        render={({ field, formState }) => (
          <FormControl>
            <FormControlLabel required>라벨</FormControlLabel>

            <FormControlField>
              <TextField {...field} width="25ch" placeholder="값을 입력하세요." status={Boolean(formState.errors.top) ? 'negative' : 'normal'} />
            </FormControlField>

            {Boolean(formState.errors.top) ? (
              <FormControlNegativeMessage>
                {formState.errors.top?.message}
              </FormControlNegativeMessage>
            ) : (
             <FormControlMessage>값을 입력해주세요.</FormControlMessage>
            )}
          </FormControl>
        )}
      />

      <Controller
        control={form.control}
        name="leading"
        rules={{
          required: {
            value: true,
            message: '필수 값입니다.'
          }
        }}
        render={({ field, formState }) => (
          <FormControl labelPlacement="leading">
            <FormControlLabel required>라벨</FormControlLabel>

            <FormControlField>
              <TextField {...field} width="25ch" placeholder="값을 입력하세요." status={Boolean(formState.errors.leading) ? 'negative' : 'normal'} />
            </FormControlField>

            {Boolean(formState.errors.leading) ? (
              <FormControlNegativeMessage>
                {formState.errors.leading?.message}
              </FormControlNegativeMessage>
            ) : (
             <FormControlMessage>값을 입력해주세요.</FormControlMessage>
            )}
          </FormControl>
        )}
      />

      <Button type="submit">제출</Button>
    </FlexBox>
  )
}

export default Demo;

Group

FormControlGroup 은 여러 개의 FormControl 을 나열해서 사용할 때 활용하는 컴포넌트입니다.

주로 labelPlacement="leading" 을 사용할 때 유용합니다.

leading 라벨은 기본적으로 라벨 텍스트 길이만큼 폭을 차지하기 때문에, 여러 FormControl 을 나열하면 라벨과 필드의 시작 위치가 제각각 달라집니다. 이때 FormControlGroup 의 labelWidth 를 조정하면 하위 FormControl 들의 라벨 영역 폭을 통일성 있게 맞출 수 있습니다.

각 FormControl 이 독립적인 grid이기 때문에 max-content 같은 콘텐츠 기반 값은 컨트롤마다 다르게 계산될 수 있습니다. 라벨 폭을 통일하려면 120px 처럼 고정된 값을 사용하세요.

값을 입력해주세요.

값을 입력해주세요.

값을 입력해주세요.

import { Button, TextField, FormControlGroup, FormControl, FormControlField, FormControlLabel, FormControlMessage, FormControlNegativeMessage } from '@montage-ui/core';
import { useForm, Controller } from 'react-hook-form';

const Demo = () => {
  const form = useForm({
    defaultValues: {
      name: '',
      email: '',
      phone: ''
    }
  });

  const fields = [
    { name: 'name', label: '이름' },
    { name: 'email', label: '이메일 주소' },
    { name: 'phone', label: '휴대폰 번호' }
  ];

  return (
    <FormControlGroup as="form" flexDirection="column" labelWidth="120px" onSubmit={form.handleSubmit(v => alert(JSON.stringify(v)))}>
      {fields.map(({ name, label }) => (
        <Controller
          key={name}
          control={form.control}
          name={name}
          rules={{
            required: {
              value: true,
              message: '필수 값입니다.'
            }
          }}
          render={({ field, formState }) => (
            <FormControl labelPlacement="leading">
              <FormControlLabel required>{label}</FormControlLabel>

              <FormControlField>
                <TextField {...field} width="25ch" placeholder="값을 입력하세요." status={Boolean(formState.errors[name]) ? 'negative' : 'normal'} />
              </FormControlField>

              {Boolean(formState.errors[name]) ? (
                <FormControlNegativeMessage>
                  {formState.errors[name]?.message}
                </FormControlNegativeMessage>
              ) : (
               <FormControlMessage>값을 입력해주세요.</FormControlMessage>
              )}
            </FormControl>
          )}
        />
      ))}

      <Button type="submit">제출</Button>
    </FormControlGroup>
  )
}

export default Demo;

Size

2가지 크기를 사용할 수 있습니다.

TextField, DatePicker, DateRangePicker, TimePicker의 크기가 자동으로 전파됩니다.

Medium

값을 입력해주세요.

값을 입력해주세요.

import { Button, FlexBox, TextField, FormControl, FormControlField, FormControlLabel, FormControlMessage, FormControlNegativeMessage, FormControlMessageAccessory } from '@montage-ui/core';
import { useForm, Controller } from 'react-hook-form';

const Demo = () => {
  const form = useForm({
    defaultValues: {
      top: '',
      leading: ''
    }
  });

  return (
    <FlexBox as="form" flexDirection="column" gap="12px" onSubmit={form.handleSubmit(v => alert(JSON.stringify(v)))}>
      <Controller
        control={form.control}
        name="top"
        rules={{
          required: {
            value: true,
            message: '필수 값입니다.'
          }
        }}
        render={({ field, formState }) => (
          <FormControl size="medium">
            <FormControlLabel required>라벨</FormControlLabel>

            <FormControlField>
              <TextField {...field} width="25ch" placeholder="값을 입력하세요." status={Boolean(formState.errors.top) ? 'negative' : 'normal'} />
            </FormControlField>

            {Boolean(formState.errors.top) ? (
              <FormControlNegativeMessage>
                {formState.errors.top?.message}
              </FormControlNegativeMessage>
            ) : (
             <FormControlMessage>값을 입력해주세요.</FormControlMessage>
            )}
          </FormControl>
        )}
      />

      <Controller
        control={form.control}
        name="leading"
        rules={{
          required: {
            value: true,
            message: '필수 값입니다.'
          }
        }}
        render={({ field, formState }) => (
          <FormControl labelPlacement="leading" size="medium">
            <FormControlLabel required>라벨</FormControlLabel>

            <FormControlField>
              <TextField {...field} width="25ch" placeholder="값을 입력하세요." status={Boolean(formState.errors.leading) ? 'negative' : 'normal'} />
            </FormControlField>

            {Boolean(formState.errors.leading) ? (
              <FormControlNegativeMessage>
                {formState.errors.leading?.message}
              </FormControlNegativeMessage>
            ) : (
             <FormControlMessage>값을 입력해주세요.</FormControlMessage>
            )}
          </FormControl>
        )}
      />

      <Button type="submit">제출</Button>
    </FlexBox>
  )
}

export default Demo;

Large

값을 입력해주세요.

값을 입력해주세요.

import { Button, FlexBox, TextField, FormControl, FormControlField, FormControlLabel, FormControlMessage, FormControlNegativeMessage, FormControlMessageAccessory } from '@montage-ui/core';
import { useForm, Controller } from 'react-hook-form';

const Demo = () => {
  const form = useForm({
    defaultValues: {
      top: '',
      leading: ''
    }
  });

  return (
    <FlexBox as="form" flexDirection="column" gap="12px" onSubmit={form.handleSubmit(v => alert(JSON.stringify(v)))}>
      <Controller
        control={form.control}
        name="top"
        rules={{
          required: {
            value: true,
            message: '필수 값입니다.'
          }
        }}
        render={({ field, formState }) => (
          <FormControl size="large">
            <FormControlLabel required>라벨</FormControlLabel>

            <FormControlField>
              <TextField {...field} width="25ch" placeholder="값을 입력하세요." status={Boolean(formState.errors.top) ? 'negative' : 'normal'} />
            </FormControlField>

            {Boolean(formState.errors.top) ? (
              <FormControlNegativeMessage>
                {formState.errors.top?.message}
              </FormControlNegativeMessage>
            ) : (
             <FormControlMessage>값을 입력해주세요.</FormControlMessage>
            )}
          </FormControl>
        )}
      />

      <Controller
        control={form.control}
        name="leading"
        rules={{
          required: {
            value: true,
            message: '필수 값입니다.'
          }
        }}
        render={({ field, formState }) => (
          <FormControl labelPlacement="leading" size="large">
            <FormControlLabel required>라벨</FormControlLabel>

            <FormControlField>
              <TextField {...field} width="25ch" placeholder="값을 입력하세요." status={Boolean(formState.errors.leading) ? 'negative' : 'normal'} />
            </FormControlField>

            {Boolean(formState.errors.leading) ? (
              <FormControlNegativeMessage>
                {formState.errors.leading?.message}
              </FormControlNegativeMessage>
            ) : (
             <FormControlMessage>값을 입력해주세요.</FormControlMessage>
            )}
          </FormControl>
        )}
      />

      <Button type="submit">제출</Button>
    </FlexBox>
  )
}

export default Demo;

With message accessory

Message 종류 컴포넌트에 FormControlMessageAccessory 를 사용하여 accessory를 추가할 수 있습니다.

  • character-counter (default)
  • custom

값을 입력해주세요.0/20

import { Button, FlexBox, TextField, FormControl, FormControlField, FormControlLabel, FormControlMessage, FormControlNegativeMessage, FormControlMessageAccessory } from '@montage-ui/core';
import { useForm, Controller } from 'react-hook-form';

const Demo = () => {
  const form = useForm({
    defaultValues: {
      content: ''
    }
  });

  return (
    <FlexBox as="form" flexDirection="column" gap="12px" onSubmit={form.handleSubmit(v => alert(JSON.stringify(v)))}>
      <Controller
        control={form.control}
        name="content"
        rules={{
          required: {
           value: true,
           message: '필수 값입니다.'
          },
          maxLength: {
            value: 20,
            message: '최대 입력가능 글자를 초과했습니다.'
          }
        }}
        render={({ field, formState }) => (
          <FormControl>
            <FormControlLabel required>라벨</FormControlLabel>

            <FormControlField>
              <TextField {...field} width="25ch" placeholder="값을 입력하세요." status={Boolean(formState.errors.content) ? 'negative' : 'normal'} />
            </FormControlField>

            {Boolean(formState.errors.content) ? (
              <FormControlNegativeMessage accessory={<FormControlMessageAccessory length={field.value.length} maxLength={20} />}>
                {formState.errors.content?.message}
              </FormControlNegativeMessage>
            ) : (
             <FormControlMessage accessory={<FormControlMessageAccessory length={field.value.length} maxLength={20} />}>값을 입력해주세요.</FormControlMessage>
            )}
          </FormControl>
        )}
      />

      <Button type="submit">제출</Button>
    </FlexBox>
  )
}

export default Demo;

Common issue

form.reset() 이 동작하지 않는 경우가 있습니다. 이 경우 defaultValue 가 설정되어 있는지 확인하세요.

react-hook-form 은 undefined value를 지원하지 않습니다.

Accessibility

  • Unique id를 생성하여 label의 htmlFor, input의 id를 연결합니다.
  • aria-describedby, aria-labelledby를 통해 추가 메시지를 연결합니다.

API

FormControlGroup

NameTypesdefaultValue
as
ElementType
-
labelWidth
Property.GridTemplateColumns<string | number> | undefined
-
children
ReactNode
-
sx
SxProp
-
flexDirection
Property.FlexDirection | undefined
-
flexWrap
Property.FlexWrap | undefined
-
justifyContent
Property.JustifyContent | undefined
-
alignItems
Property.AlignItems | undefined
-
alignContent
Property.AlignContent | undefined
-
order
Property.Order | undefined
-
flex
Property.Flex<string | number> | undefined
-
flexGrow
Property.FlexGrow | undefined
-
flexShrink
Property.FlexShrink | undefined
-
flexBasis
Property.FlexBasis<string | number> | undefined
-
alignSelf
Property.AlignSelf | undefined
-
gap
Property.Gap<string | number> | undefined
-
rowGap
Property.RowGap<string | number> | undefined
-
columnGap
Property.ColumnGap<string | number> | undefined
-
xl
Merge<Merge<Pick<FormControlGroupDefaultProps, "labelWidth">, FlexBoxResponsiveProps>, { sx?: CSSInterpolation; }> | undefined
-
lg
Merge<Merge<Pick<FormControlGroupDefaultProps, "labelWidth">, FlexBoxResponsiveProps>, { sx?: CSSInterpolation; }> | undefined
-
md
Merge<Merge<Pick<FormControlGroupDefaultProps, "labelWidth">, FlexBoxResponsiveProps>, { sx?: CSSInterpolation; }> | undefined
-
sm
Merge<Merge<Pick<FormControlGroupDefaultProps, "labelWidth">, FlexBoxResponsiveProps>, { sx?: CSSInterpolation; }> | undefined
-
xs
Merge<Merge<Pick<FormControlGroupDefaultProps, "labelWidth">, FlexBoxResponsiveProps>, { sx?: CSSInterpolation; }> | undefined
-

FormControl

NameTypesdefaultValue
as
ElementType
-
size
"medium" | "large"
"large"
labelPlacement
"top" | "leading"
"top"
children
ReactNode
-
sx
SxProp
-
flexDirection
Property.FlexDirection | undefined
-
flexWrap
Property.FlexWrap | undefined
-
justifyContent
Property.JustifyContent | undefined
-
alignItems
Property.AlignItems | undefined
-
alignContent
Property.AlignContent | undefined
-
order
Property.Order | undefined
-
flex
Property.Flex<string | number> | undefined
-
flexGrow
Property.FlexGrow | undefined
-
flexShrink
Property.FlexShrink | undefined
-
flexBasis
Property.FlexBasis<string | number> | undefined
-
alignSelf
Property.AlignSelf | undefined
-
gap
Property.Gap<string | number> | undefined
-
rowGap
Property.RowGap<string | number> | undefined
-
columnGap
Property.ColumnGap<string | number> | undefined
-
xl
Merge<Merge<Pick<FormControlDefaultProps, "size" | "labelPlacement">, FlexBoxResponsiveProps>, { sx?: CSSInterpolation; }> | undefined
-
lg
Merge<Merge<Pick<FormControlDefaultProps, "size" | "labelPlacement">, FlexBoxResponsiveProps>, { sx?: CSSInterpolation; }> | undefined
-
md
Merge<Merge<Pick<FormControlDefaultProps, "size" | "labelPlacement">, FlexBoxResponsiveProps>, { sx?: CSSInterpolation; }> | undefined
-
sm
Merge<Merge<Pick<FormControlDefaultProps, "size" | "labelPlacement">, FlexBoxResponsiveProps>, { sx?: CSSInterpolation; }> | undefined
-
xs
Merge<Merge<Pick<FormControlDefaultProps, "size" | "labelPlacement">, FlexBoxResponsiveProps>, { sx?: CSSInterpolation; }> | undefined
-

FormControlLabel

NameTypesdefaultValue
required
boolean
-
color
ThemeColorsToken
-
children
ReactNode
-
sx
SxProp
-
xl
Merge<Pick<TypographyDefaultProps, "variant" | "weight" | "align">, { sx?: CSSInterpolation; }> | undefined
-
lg
Merge<Pick<TypographyDefaultProps, "variant" | "weight" | "align">, { sx?: CSSInterpolation; }> | undefined
-
md
Merge<Pick<TypographyDefaultProps, "variant" | "weight" | "align">, { sx?: CSSInterpolation; }> | undefined
-
sm
Merge<Pick<TypographyDefaultProps, "variant" | "weight" | "align">, { sx?: CSSInterpolation; }> | undefined
-
xs
Merge<Pick<TypographyDefaultProps, "variant" | "weight" | "align">, { sx?: CSSInterpolation; }> | undefined
-
variant
"display1" | "display2" | "display3" | "title1" | "title2" | "title3" | "heading1" | "heading2" | "headline1" | "headline2" | "body1" | "body2" | "label1" | "label2" | "caption1" | "caption2" | "body1-reading" | "body2-reading" | "label1-reading"
-
weight
"medium" | "regular" | "bold"
-
align
Property.TextAlign | undefined
-
noWrap
boolean
-
display
Property.Display | undefined
-

FormControlField

children으로 form 요소 컴포넌트를 넣어 사용합니다.

FormControlMessage

NameTypesdefaultValue
as
ElementType
-
accessory
ReactNode
-
color
ThemeColorsToken
-
children
ReactNode
-
sx
SxProp
-
xl
Merge<Pick<TypographyDefaultProps, "variant" | "weight" | "align">, { sx?: CSSInterpolation; }> | undefined
-
lg
Merge<Pick<TypographyDefaultProps, "variant" | "weight" | "align">, { sx?: CSSInterpolation; }> | undefined
-
md
Merge<Pick<TypographyDefaultProps, "variant" | "weight" | "align">, { sx?: CSSInterpolation; }> | undefined
-
sm
Merge<Pick<TypographyDefaultProps, "variant" | "weight" | "align">, { sx?: CSSInterpolation; }> | undefined
-
xs
Merge<Pick<TypographyDefaultProps, "variant" | "weight" | "align">, { sx?: CSSInterpolation; }> | undefined
-
variant
"display1" | "display2" | "display3" | "title1" | "title2" | "title3" | "heading1" | "heading2" | "headline1" | "headline2" | "body1" | "body2" | "label1" | "label2" | "caption1" | "caption2" | "body1-reading" | "body2-reading" | "label1-reading"
-
weight
"medium" | "regular" | "bold"
-
align
Property.TextAlign | undefined
-
noWrap
boolean
-
display
Property.Display | undefined
-

FormControlNegativeMessage

NameTypesdefaultValue
as
ElementType
-
accessory
ReactNode
-
color
ThemeColorsToken
-
children
ReactNode
-
sx
SxProp
-
xl
Merge<Pick<TypographyDefaultProps, "variant" | "weight" | "align">, { sx?: CSSInterpolation; }> | undefined
-
lg
Merge<Pick<TypographyDefaultProps, "variant" | "weight" | "align">, { sx?: CSSInterpolation; }> | undefined
-
md
Merge<Pick<TypographyDefaultProps, "variant" | "weight" | "align">, { sx?: CSSInterpolation; }> | undefined
-
sm
Merge<Pick<TypographyDefaultProps, "variant" | "weight" | "align">, { sx?: CSSInterpolation; }> | undefined
-
xs
Merge<Pick<TypographyDefaultProps, "variant" | "weight" | "align">, { sx?: CSSInterpolation; }> | undefined
-
variant
"display1" | "display2" | "display3" | "title1" | "title2" | "title3" | "heading1" | "heading2" | "headline1" | "headline2" | "body1" | "body2" | "label1" | "label2" | "caption1" | "caption2" | "body1-reading" | "body2-reading" | "label1-reading"
-
weight
"medium" | "regular" | "bold"
-
align
Property.TextAlign | undefined
-
noWrap
boolean
-
display
Property.Display | undefined
-

FormControlPositiveMessage

NameTypesdefaultValue
as
ElementType
-
accessory
ReactNode
-
color
ThemeColorsToken
-
children
ReactNode
-
sx
SxProp
-
xl
Merge<Pick<TypographyDefaultProps, "variant" | "weight" | "align">, { sx?: CSSInterpolation; }> | undefined
-
lg
Merge<Pick<TypographyDefaultProps, "variant" | "weight" | "align">, { sx?: CSSInterpolation; }> | undefined
-
md
Merge<Pick<TypographyDefaultProps, "variant" | "weight" | "align">, { sx?: CSSInterpolation; }> | undefined
-
sm
Merge<Pick<TypographyDefaultProps, "variant" | "weight" | "align">, { sx?: CSSInterpolation; }> | undefined
-
xs
Merge<Pick<TypographyDefaultProps, "variant" | "weight" | "align">, { sx?: CSSInterpolation; }> | undefined
-
variant
"display1" | "display2" | "display3" | "title1" | "title2" | "title3" | "heading1" | "heading2" | "headline1" | "headline2" | "body1" | "body2" | "label1" | "label2" | "caption1" | "caption2" | "body1-reading" | "body2-reading" | "label1-reading"
-
weight
"medium" | "regular" | "bold"
-
align
Property.TextAlign | undefined
-
noWrap
boolean
-
display
Property.Display | undefined
-

FormControlMessageAccessory

NameTypesdefaultValue
length
number
0
maxLength
number
0
variant
"custom" | "character-counter"
"character-counter"
sx
SxProp
-

© 2026 Wanted Lab, Inc.