React Meritz 컴포넌트 스터디 가이드

components 폴더의 JSX 원본 코드와 pages 폴더의 사용 가이드 JSX를 한 페이지에서 같이 보는 학습용 문서입니다.

생성 시각: 2026. 8. 6. 오후 2:39:52 컴포넌트 JSX: 30개 가이드 JSX: 19개

읽는 순서

그룹 바로가기

Badge

2개 JSX 컴포넌트가 있습니다.

Badge

Badge

짧은 상태값, 카운트, 라벨을 인라인으로 표현하는 배지 컴포넌트입니다.

Top

컴포넌트 코드

components/Badge/Badge.jsx

import { cn } from '@/utils/cn'
import styles from './Badge.module.css'

const BADGE_TYPES = {
  text: styles.text,
  number: styles.number,
  iconText: styles.iconText,
}

const BADGE_TONES = {
  red: styles.red,
  gray: styles.gray,
  orange: styles.orange,
  green: styles.green,
  blue: styles.blue,
  dark: styles.dark,
  light: styles.light,
}

function renderIcon(icon) {
  if (!icon) return null

  return (
    <span className={styles.icon} aria-hidden="true">
      {icon}
    </span>
  )
}

export default function Badge({
  icon,
  text,
  children,
  type = icon ? 'iconText' : 'text',
  tone = 'red',
  hasBorder = false,
  className,
  ...props
}) {
  const label = text ?? children
  const badgeType = BADGE_TYPES[type] ?? BADGE_TYPES.text
  const badgeTone = BADGE_TONES[tone] ?? BADGE_TONES.red

  return (
    <span className={cn(styles.badge, badgeType, badgeTone, hasBorder && styles.border, className)} {...props}>
      {type === 'iconText' && renderIcon(icon)}
      <span className={styles.label}>{label}</span>
    </span>
  )
}

상세 설명

주요 props
icontextchildrentypetonehasBorderclassName
  • type 값으로 text, number, iconText 형태를 선택하고, tone 값으로 색상 토큰을 바꿉니다.
  • text가 없으면 children을 라벨로 사용하므로 <Badge>문구</Badge>와 text prop 방식이 모두 가능합니다.
  • icon이 전달되면 기본 type이 iconText로 바뀌며, 아이콘 영역은 aria-hidden 처리되어 텍스트 라벨을 중심으로 읽힙니다.
  • 정의되지 않은 type/tone 값은 안전하게 기본값 text/red로 대체됩니다.

Badge

BadgeGroup

여러 Badge를 일정 간격으로 묶어 렌더링하는 그룹 컴포넌트입니다.

Top

컴포넌트 코드

components/Badge/BadgeGroup.jsx

import { Children } from 'react'
import { cn } from '@/utils/cn'
import styles from './BadgeGroup.module.css'

export default function BadgeGroup({ children, className, ...props }) {
  const badges = Children.toArray(children).filter(Boolean)

  if (badges.length <= 1) {
    return badges[0] ?? null
  }

  return (
    <div className={cn(styles.group, className)} {...props}>
      {badges}
    </div>
  )
}

상세 설명

주요 props
children, className, ...props
  • Children.toArray(children).filter(Boolean)로 null, false 같은 조건부 렌더링 결과를 제거합니다.
  • 배지가 1개 이하이면 불필요한 래퍼 div를 만들지 않고 자식만 반환합니다.
  • 2개 이상일 때만 group 스타일과 className을 합쳐 div로 감쌉니다.

사용 가이드 JSX

pages/BadgeGuide.jsx
import Badge from '@/components/Badge/Badge'
import BadgeGroup from '@/components/Badge/BadgeGroup'

function ExampleIcon() {
  return (
    <svg viewBox="0 0 16 16" fill="none">
      <rect x="3" y="3" width="10" height="10" rx="2" stroke="currentColor" strokeWidth="1.5" />
    </svg>
  )
}

export default function BadgeGuide() {
  return (
    <section className="flex flex-col gap-8 p-6" style={{ backgroundColor:'#f0f0f0' }}>
      <h2 className="text-title-s">Badge</h2>

      <div className="flex flex-col gap-3">
        <h3 className="text-body-s">아이콘 + 레이블</h3>
        <BadgeGroup>
          <Badge type="iconText" tone="red" icon={<ExampleIcon />} text="Label" />
          <Badge type="iconText" tone="gray" icon={<ExampleIcon />} text="Label" />
          <Badge type="iconText" tone="orange" icon={<ExampleIcon />} text="Label" />
          <Badge type="iconText" tone="green" icon={<ExampleIcon />} text="Label" />
          <Badge type="iconText" tone="blue" icon={<ExampleIcon />} text="Label" />
        </BadgeGroup>
      </div>

        <div className="flex flex-col gap-3">
            <h3 className="text-body-s">보더있는 레이블</h3>
            <BadgeGroup>
                <Badge hasBorder={true} tone="red" text="Label" />
                <Badge hasBorder={true} tone="gray" text="Label" />
                <Badge hasBorder={true} tone="orange" text="Label" />
                <Badge hasBorder={true} tone="green" text="Label" />
                <Badge hasBorder={true} tone="blue" text="Label" />
            </BadgeGroup>
        </div>

      <div className="flex flex-col gap-3">
        <h3 className="text-body-s">보더없는 레이블</h3>
        <BadgeGroup>
          <Badge tone="red" text="Label" />
          <Badge tone="gray" text="Label" />
          <Badge tone="orange" text="Label" />
          <Badge tone="green" text="Label" />
          <Badge tone="blue" text="Label" />
        </BadgeGroup>
      </div>

      <div className="flex flex-col gap-3">
        <h3 className="text-body-s">숫자 레이블</h3>
        <BadgeGroup>
          <Badge type="number" tone="dark" text="00" />
          <Badge type="number" tone="light" text="00" />
        </BadgeGroup>
      </div>
    </section>
  )
}

Button

3개 JSX 컴포넌트가 있습니다.

Button

Button

variant, size, 좌우 아이콘, 숫자 배지를 지원하는 기본 버튼 컴포넌트입니다.

Top

컴포넌트 코드

components/Button/Button.jsx

import Badge from '@/components/Badge/Badge'
import { cn } from '@/utils/cn'
import styles from './Button.module.css'

const BUTTON_VARIANTS = {
  primary: styles.primary,
  secondary: styles.secondary,
  tertiary: styles.tertiary,
  outline: styles.outline,
  outlineLight: styles.outlineLight,
  gradation: styles.gradation,
}

const BUTTON_SIZES = {
  xxl: styles.xxl,
  xl: styles.xl,
  lg: styles.lg,
  md: styles.md,
  sm: styles.sm,
}

const BUTTON_ICON_SIZES = {
  xxl: 24,
  xl: 24,
  lg: 24,
  md: 16,
  sm: 16,
}

function renderIcon(Icon, size, stroke, fill) {
  if (!Icon) return null

  return (
    <span className={styles.icon} aria-hidden="true">
      <Icon size={size} stroke={stroke} fill={fill} />
    </span>
  )
}

export default function Button({
  children,
  label,
  leftIcon: LeftIcon,
  rightIcon: RightIcon,
  iconSize,
  iconStroke = 'currentColor',
  iconFill,
  badge,
  badgeTone,
  variant = 'primary',
  size = 'lg',
  fullWidth = false,
  className,
  type = 'button',
  ...props
}) {
  const buttonVariant = BUTTON_VARIANTS[variant] ?? BUTTON_VARIANTS.primary
  const buttonSize = BUTTON_SIZES[size] ?? BUTTON_SIZES.lg
  const buttonLabel = label ?? children
  const buttonBadgeTone = badgeTone ?? (variant === 'secondary' ? 'light' : 'dark')
  const buttonIconSize = iconSize ?? BUTTON_ICON_SIZES[size] ?? BUTTON_ICON_SIZES.lg

  return (
    <button
      type={type}
      className={cn(styles.button, buttonVariant, buttonSize, fullWidth && styles.fullWidth, className)}
      {...props}
    >
      {renderIcon(LeftIcon, buttonIconSize, iconStroke, iconFill)}
      {buttonLabel && <span className={styles.label}>{buttonLabel}</span>}
      {renderIcon(RightIcon, buttonIconSize, iconStroke, iconFill)}
      {badge && <Badge type="number" tone={buttonBadgeTone} text={badge} className={styles.badge} />}
    </button>
  )
}

상세 설명

주요 props
childrenlabelleftIconrightIconiconSizeiconStrokeiconFillbadgebadgeTonevariantsizefullWidthclassNametype
  • BUTTON_VARIANTS와 BUTTON_SIZES 맵으로 prop 문자열을 CSS module 클래스에 연결합니다.
  • label prop이 있으면 label을 우선 사용하고, 없으면 children을 버튼 문구로 사용합니다.
  • leftIcon/rightIcon은 컴포넌트 타입을 받아 내부 renderIcon에서 크기, stroke, fill을 주입합니다.
  • badge가 있으면 Badge 컴포넌트를 number 타입으로 추가하며 secondary 버튼은 기본 light tone, 나머지는 dark tone을 씁니다.
  • fullWidth는 ButtonGroup 같은 상위 레이아웃에서 버튼 폭을 강제로 맞출 때 사용됩니다.

Button

ButtonGroup

하나 또는 두 개의 Button을 정해진 비율로 배치하는 버튼 그룹입니다.

Top

컴포넌트 코드

components/Button/ButtonGroup.jsx

import { Children, cloneElement, isValidElement } from 'react'
import { cn } from '@/utils/cn'
import styles from './ButtonGroup.module.css'

const BUTTON_GROUP_RATIOS = {
  equal: styles.equal,
  '50-50': styles.equal,
  '35-65': styles.leftSmall,
  '65-35': styles.leftLarge,
}

export default function ButtonGroup({ children, ratio = 'equal', floating = false, className, ...props }) {
  const items = Children.toArray(children).filter(Boolean)
  const groupRatio = items.length > 1 ? BUTTON_GROUP_RATIOS[ratio] ?? BUTTON_GROUP_RATIOS.equal : styles.single

  return (
    <div className={cn(styles.group, groupRatio, floating && styles.floating, className)} {...props}>
      {items.map((child) => {
        if (!isValidElement(child)) return child

        return cloneElement(child, {
          fullWidth: true,
          ...child.props,
        })
      })}
    </div>
  )
}

상세 설명

주요 props
children, ratio
  • children을 배열로 정리한 뒤, 버튼이 1개면 single 스타일을 적용합니다.
  • 2개 이상이면 ratio 값에 따라 equal, 35-65, 65-35 배치를 선택합니다.
  • cloneElement로 각 자식 Button에 fullWidth=true를 주입해 그룹 안에서 균등하게 늘어나도록 합니다.
  • floating 옵션은 화면 하단 고정 CTA 같은 사용처를 위한 스타일 토글입니다.

Button

ButtonIcon

아이콘만 표시하는 버튼이며 hover/press/disabled 상태별 색상 변경을 자체 상태로 처리합니다.

Top

컴포넌트 코드

components/Button/ButtonIcon.jsx

import { useState } from 'react'
import { cn } from '@/utils/cn'
import styles from './ButtonIcon.module.css'

export default function ButtonIcon({
  icon: Icon,
  size = 24,
  stroke,
  fill,
  hoverStroke,
  hoverFill,
  activeStroke,
  activeFill,
  disabledStroke,
  disabledFill,
  label,
  className,
  hoverBackground = true,
  disabled = false,
  type = 'button',
  onMouseEnter,
  onMouseLeave,
  onMouseDown,
  onMouseUp,
  onTouchStart,
  onTouchEnd,
  onTouchCancel,
  ...props
}) {
  const [isHovered, setIsHovered] = useState(false)
  const [isPressed, setIsPressed] = useState(false)
  const ariaLabel = props['aria-label'] ?? label
  const iconStroke = disabled
    ? disabledStroke ?? stroke
    : isPressed
      ? activeStroke ?? hoverStroke ?? stroke
      : isHovered
        ? hoverStroke ?? stroke
        : stroke
  const iconFill = disabled
    ? disabledFill ?? fill
    : isPressed
      ? activeFill ?? hoverFill ?? fill
      : isHovered
        ? hoverFill ?? fill
        : fill

  return (
    <button
      type={type}
      className={cn(styles.button, hoverBackground && styles.hoverBackground, className)}
      aria-label={ariaLabel}
      disabled={disabled}
      onMouseEnter={(event) => {
        setIsHovered(true)
        onMouseEnter?.(event)
      }}
      onMouseLeave={(event) => {
        setIsHovered(false)
        setIsPressed(false)
        onMouseLeave?.(event)
      }}
      onMouseDown={(event) => {
        setIsPressed(true)
        onMouseDown?.(event)
      }}
      onMouseUp={(event) => {
        setIsPressed(false)
        onMouseUp?.(event)
      }}
      onTouchStart={(event) => {
        setIsPressed(true)
        onTouchStart?.(event)
      }}
      onTouchEnd={(event) => {
        setIsPressed(false)
        onTouchEnd?.(event)
      }}
      onTouchCancel={(event) => {
        setIsPressed(false)
        onTouchCancel?.(event)
      }}
      {...props}
    >
      {Icon && <Icon size={size} stroke={iconStroke} fill={iconFill} />}
    </button>
  )
}

상세 설명

주요 props
iconsizestrokefillhoverStrokehoverFillactiveStrokeactiveFilldisabledStrokedisabledFilllabelclassNamehoverBackgrounddisabledtypeonMouseEnteronMouseLeaveonMouseDownonMouseUponTouchStartonTouchEndonTouchCancel
  • icon prop은 SVG 컴포넌트를 받고, size/stroke/fill 계열 prop을 아이콘에 전달합니다.
  • 마우스 hover와 press 상태를 useState로 관리해 hoverStroke, activeStroke 같은 색상 우선순위를 계산합니다.
  • 터치 이벤트도 onTouchStart/onTouchEnd/onTouchCancel로 처리해 모바일 탭 상태를 반영합니다.
  • 텍스트가 없는 버튼이므로 label 또는 aria-label을 전달해 접근성 이름을 만들어야 합니다.

사용 가이드 JSX

pages/ButtonGuide.jsx
import Button from '@/components/Button/Button'
import ButtonGroup from '@/components/Button/ButtonGroup'
import ButtonIcon from '@/components/Button/ButtonIcon'
import FilterIcon from '@/components/icons/FilterIcon'
import TextEditIcon from '@/components/icons/TextEditIcon'
import UserAddIcon from '@/components/icons/UserAddIcon'

const buttonSizes = ['xxl', 'xl', 'lg', 'md', 'sm']

function ButtonVariantSection({ variant }) {
  return (
    <div className="flex flex-col gap-3">
      <h3 className="text-body-s">{variant}</h3>
      <div className="flex flex-wrap items-center gap-4">
        {buttonSizes.map((size) => (
          <Button
            key={`${variant}-${size}`}
            variant={variant}
            size={size}
            leftIcon={TextEditIcon}
            rightIcon={UserAddIcon}
            badge={size === 'xxl' ? '00' : undefined}
          >
            Button
          </Button>
        ))}
      </div>
    </div>
  )
}

export default function ButtonGuide() {
  return (
    <section className="flex flex-col gap-8 p-6" style={{ backgroundColor: '#f0f0f0' }}>
      <h2 className="text-title-s">Button</h2>

      <div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
        <ButtonVariantSection variant="primary" />
        <ButtonVariantSection variant="secondary" />
        <ButtonVariantSection variant="tertiary" />
        <ButtonVariantSection variant="outline" />
        <ButtonVariantSection variant="outlineLight" />
        <ButtonVariantSection variant="gradation" />
      </div>

      <div className="flex flex-col gap-3">
        <h3 className="text-body-s">default label</h3>
        <div className="flex flex-wrap items-center gap-4">
          <Button>Button</Button>
          <Button variant="secondary">Button</Button>
          <Button variant="tertiary">Button</Button>
          <Button variant="outline">Button</Button>
        </div>
      </div>

      <div className="flex flex-col gap-3">
        <h3 className="text-body-s">group</h3>
        <div className="flex max-w-3xl flex-col gap-4">
          <ButtonGroup>
            <Button size="xxl">Button</Button>
          </ButtonGroup>
          <ButtonGroup ratio="50-50">
            <Button variant="outline" size="xxl">Button</Button>
            <Button size="xxl">Button</Button>
          </ButtonGroup>
          <ButtonGroup ratio="35-65">
            <Button variant="outline" size="xxl">Button</Button>
            <Button size="xxl">Button</Button>
          </ButtonGroup>
          <ButtonGroup ratio="65-35">
            <Button size="xxl">Button</Button>
            <Button variant="outline" size="xxl">Button</Button>
          </ButtonGroup>
        </div>
      </div>

      <div className="flex flex-col gap-3">
        <h3 className="text-body-s">icon only</h3>
        <div className="flex flex-wrap items-center gap-4">
          <ButtonIcon icon={TextEditIcon} label="Edit text" hoverStroke="#fff" activeStroke="#fff" />
          <ButtonIcon icon={TextEditIcon} label="Edit text" disabledStroke="#fff" disabled />
          <ButtonIcon icon={UserAddIcon} label="Add user" hoverBackground={false} />
          <ButtonIcon icon={UserAddIcon} label="Add user" disabledStroke="#fff" disabledFill="#e0e0e0" disabled />
          <ButtonIcon icon={FilterIcon} label="Filter" size={32} hoverStroke="#fff" hoverFill="#222" />
          <ButtonIcon icon={FilterIcon} label="Filter" size={32} disabledStroke="#fff" disabledFill="#e0e0e0" disabled />
        </div>
      </div>
    </section>
  )
}

Calendar

2개 JSX 컴포넌트가 있습니다.

Calendar

DateInline

react-datepicker를 inline 모드로 사용해 월간 달력과 하단 CTA 버튼을 제공하는 컴포넌트입니다.

Top

컴포넌트 코드

components/Calendar/DateInline.jsx

import { useState } from 'react'
import DatePicker from 'react-datepicker'
import 'react-datepicker/dist/react-datepicker.css'
import { cn } from '@/utils/cn'
import styles from './DateInline.module.css'

function formatMonthTitle(date) {
  return `${date.getFullYear()}년  ${String(date.getMonth() + 1).padStart(2, '0')}월`
}

function toDateKey(date) {
  const year = date.getFullYear()
  const month = String(date.getMonth() + 1).padStart(2, '0')
  const day = String(date.getDate()).padStart(2, '0')

  return `${year}-${month}-${day}`
}

function getNextMonth(date, amount) {
  return new Date(date.getFullYear(), date.getMonth() + amount, 1)
}

export default function DateInline({
  selected,
  defaultSelected = new Date(),
  onChange,
  markers = [],
  buttonLabel = '버튼명',
  onButtonClick,
  className,
  calendarClassName,
  ...props
}) {
  const [internalSelected, setInternalSelected] = useState(defaultSelected)
  const [viewDate, setViewDate] = useState(selected ?? defaultSelected)
  const selectedDate = selected ?? internalSelected
  const markerSet = new Set(markers.map((date) => toDateKey(date)))

  function handleChange(date) {
    setInternalSelected(date)
    onChange?.(date)
  }

  return (
    <div className={cn(styles.wrap, className)}>
      <DatePicker
        {...props}
        inline
        selected={selectedDate}
        calendarClassName={cn(styles.calendar, calendarClassName)}
        onChange={handleChange}
        onMonthChange={setViewDate}
        renderCustomHeader={({ date, decreaseMonth, increaseMonth }) => (
          <div className={styles.header}>
            <strong className={styles.title}>{formatMonthTitle(date)}</strong>
            <div className={styles.nav}>
              <button
                className={styles.navButton}
                type="button"
                aria-label="이전 달"
                onClick={() => {
                  setViewDate(getNextMonth(date, -1))
                  decreaseMonth()
                }}
              >
                ‹
              </button>
              <button
                className={styles.navButton}
                type="button"
                aria-label="다음 달"
                onClick={() => {
                  setViewDate(getNextMonth(date, 1))
                  increaseMonth()
                }}
              >
                ›
              </button>
            </div>
          </div>
        )}
        renderDayContents={(day, date) => {
          const isOutsideMonth = date.getMonth() !== viewDate.getMonth()
          const hasMarker = markerSet.has(toDateKey(date))

          return (
            <span className={cn(styles.dayContent, isOutsideMonth && styles.outsideDay)}>
              <span>{day}</span>
              {hasMarker && !isOutsideMonth && <span className={styles.dot} />}
            </span>
          )
        }}
      />
      {buttonLabel && (
        <button className={styles.cta} type="button" onClick={() => onButtonClick?.(selectedDate)}>
          {buttonLabel}
        </button>
      )}
    </div>
  )
}

상세 설명

주요 props
selecteddefaultSelectedonChangemarkersbuttonLabelonButtonClickclassNamecalendarClassName
  • selected가 있으면 controlled 방식, 없으면 defaultSelected 기반 internalSelected 상태를 사용합니다.
  • viewDate 상태로 현재 보고 있는 월을 추적하고, renderDayContents에서 다른 달 날짜를 흐리게 처리합니다.
  • markers 배열을 yyyy-mm-dd 키 Set으로 바꿔 해당 날짜에 점 표시를 붙입니다.
  • 커스텀 헤더에서 이전/다음 월 버튼을 직접 렌더링하고 DatePicker의 decreaseMonth/increaseMonth를 호출합니다.

Calendar

Datepicker

일자별 근무계획/메모 배지를 표시할 수 있는 확장형 inline DatePicker입니다.

Top

컴포넌트 코드

components/Calendar/Datepicker.jsx

import { useState } from 'react'
import DatePicker from 'react-datepicker'
import 'react-datepicker/dist/react-datepicker.css'
import { cn } from '@/utils/cn'
import styles from './Datepicker.module.css'

function toDateKey(date) {
  const year = date.getFullYear()
  const month = String(date.getMonth() + 1).padStart(2, '0')
  const day = String(date.getDate()).padStart(2, '0')

  return `${year}-${month}-${day}`
}

function getNextMonth(date, amount) {
  return new Date(date.getFullYear(), date.getMonth() + amount, 1)
}

function formatTitle(date) {
  const month = String(date.getMonth() + 1).padStart(2, '0')

  return `${month}월 - ${month}월 근무계획표`
}

function getPlanContent(plan) {
  if (!plan) return null
  if (typeof plan === 'string') return plan

  return plan.label ?? plan.text ?? plan.hours
}

export default function Datepicker({
  selected,
  defaultSelected = new Date(),
  onChange,
  title,
  buttonLabel = '버튼명',
  onButtonClick,
  plans = {},
  bottomText = '문구',
  className,
  calendarClassName,
  ...props
}) {
  const [internalSelected, setInternalSelected] = useState(defaultSelected)
  const [viewDate, setViewDate] = useState(selected ?? defaultSelected)
  const selectedDate = selected ?? internalSelected

  function handleChange(date) {
    setInternalSelected(date)
    onChange?.(date)
  }

  return (
    <div className={cn(styles.wrap, className)}>
      <div className={styles.top}>
        <strong className={styles.title}>{title ?? formatTitle(viewDate)}</strong>
        {buttonLabel && (
          <button className={styles.topButton} type="button" onClick={() => onButtonClick?.(selectedDate)}>
            {buttonLabel}
          </button>
        )}
      </div>

      <DatePicker
        {...props}
        inline
        selected={selectedDate}
        calendarClassName={cn(styles.calendar, calendarClassName)}
        onChange={handleChange}
        onMonthChange={setViewDate}
        renderCustomHeader={({ date, decreaseMonth, increaseMonth }) => (
          <div className={styles.header}>
            <button
              className={styles.navButton}
              type="button"
              aria-label="이전 달"
              onClick={() => {
                setViewDate(getNextMonth(date, -1))
                decreaseMonth()
              }}
            >
              ‹
            </button>
            <button
              className={styles.navButton}
              type="button"
              aria-label="다음 달"
              onClick={() => {
                setViewDate(getNextMonth(date, 1))
                increaseMonth()
              }}
            >
              ›
            </button>
          </div>
        )}
        renderDayContents={(day, date) => {
          const isOutsideMonth = date.getMonth() !== viewDate.getMonth()
          const plan = plans[toDateKey(date)]
          const planContent = getPlanContent(plan)
          const isMuted = typeof plan === 'object' && plan.muted

          return (
            <span className={cn(styles.dayContent, isOutsideMonth && styles.outsideDay)}>
              <span>{day}</span>
              {planContent && (
                <span className={cn(styles.badge, isMuted && styles.mutedBadge)}>
                  {planContent}
                </span>
              )}
            </span>
          )
        }}
      />

      {bottomText && <div className={styles.bottom}>{bottomText}</div>}
    </div>
  )
}

상세 설명

주요 props
selecteddefaultSelectedonChangetitlebuttonLabelonButtonClickplansbottomTextclassNamecalendarClassName
  • DateInline과 마찬가지로 controlled/uncontrolled selected 패턴을 모두 지원합니다.
  • plans 객체의 key를 yyyy-mm-dd로 맞추면 날짜 셀 내부에 label, text, hours 또는 문자열을 배지로 표시합니다.
  • plan 객체에 muted가 있으면 mutedBadge 스타일을 적용해 보조 상태를 표현합니다.
  • 상단 title/button, 달력, 하단 bottomText 영역으로 구성되어 근무계획 달력 같은 업무 화면에 맞춰져 있습니다.

사용 가이드 JSX

pages/CalendarGuide.jsx
import DateInline from '@/components/Calendar/DateInline'
import Datepicker from '@/components/Calendar/Datepicker'

const selectedDate = new Date(2026, 6, 1)

const markers = [
  new Date(2026, 6, 1),
  new Date(2026, 6, 6),
  new Date(2026, 6, 8),
  new Date(2026, 6, 11),
  new Date(2026, 6, 14),
  new Date(2026, 6, 15),
  new Date(2026, 6, 16),
  new Date(2026, 6, 20),
  new Date(2026, 6, 23),
  new Date(2026, 6, 25),
  new Date(2026, 6, 28),
]

const plans = Array.from({ length: 31 }, (_, index) => {
  const day = String(index + 1).padStart(2, '0')

  return [`2026-07-${day}`, '00.0h+']
}).reduce((acc, [key, value]) => {
  acc[key] = value
  return acc
}, {
  '2026-06-27': { label: '00.0h+', muted: true },
  '2026-06-28': { label: '00.0h+', muted: true },
  '2026-06-29': { label: '00.0h+', muted: true },
  '2026-06-30': { label: '00.0h+', muted: true },
})

export default function CalendarGuide() {
  return (
    <section className="flex flex-col gap-8 p-6" style={{ backgroundColor: '#f0f0f0' }}>
      <h2 className="text-title-s">Calendar</h2>

      <div className="flex flex-wrap items-start gap-6">
        <div className="flex flex-col gap-3">
          <h3 className="text-body-s">DateInline</h3>
          <DateInline defaultSelected={selectedDate} markers={markers} />
        </div>

        <div className="flex flex-col gap-3">
          <h3 className="text-body-s">Datepicker</h3>
          <Datepicker defaultSelected={selectedDate} plans={plans} />
        </div>
      </div>
    </section>
  )
}

Checkbox

2개 JSX 컴포넌트가 있습니다.

Checkbox

Checkbox

기본 checkbox input을 숨기고 커스텀 control과 라벨을 함께 렌더링하는 컴포넌트입니다.

Top

컴포넌트 코드

components/Checkbox/Checkbox.jsx

import { cn } from '@/utils/cn'
import styles from './Checkbox.module.css'

export default function Checkbox({
  children,
  label,
  rightText,
  rightContent,
  rightTone = 'primary',
  className,
  controlClassName,
  textClassName,
  rightClassName,
  ...props
}) {
  const checkboxLabel = label ?? children
  const checkboxRightContent = rightContent ?? rightText

  return (
    <label className={cn(styles.checkbox, className)}>
      <input {...props} className={styles.input} type="checkbox" />
      <span className={cn(styles.control, controlClassName)} aria-hidden="true" />
      {checkboxLabel && <span className={cn(styles.text, textClassName)}>{checkboxLabel}</span>}
      {checkboxRightContent && (
        <span className={cn(styles.right, styles[rightTone], rightClassName)}>{checkboxRightContent}</span>
      )}
    </label>
  )
}

상세 설명

주요 props
childrenlabelrightTextrightContentrightToneclassNamecontrolClassNametextClassNamerightClassName
  • label prop 또는 children을 왼쪽 텍스트로 사용합니다.
  • rightText/rightContent로 우측 보조 문구나 JSX를 붙일 수 있고 rightTone으로 색상을 바꿉니다.
  • checked, defaultChecked, name, value, onChange 같은 input 기본 prop은 ...props로 그대로 전달됩니다.
  • label 전체가 클릭 영역이므로 모바일에서 터치하기 쉽습니다.

Checkbox

CheckboxGroup

Checkbox들을 vertical 또는 horizontal 방향으로 묶는 그룹 컴포넌트입니다.

Top

컴포넌트 코드

components/Checkbox/CheckboxGroup.jsx

import { Children } from 'react'
import { cn } from '@/utils/cn'
import styles from './CheckboxGroup.module.css'

const CHECKBOX_GROUP_DIRECTIONS = {
  horizontal: styles.horizontal,
  vertical: styles.vertical,
}

export default function CheckboxGroup({
  children,
  direction = 'vertical',
  className,
  ...props
}) {
  const items = Children.toArray(children).filter(Boolean)
  const groupDirection = CHECKBOX_GROUP_DIRECTIONS[direction] ?? CHECKBOX_GROUP_DIRECTIONS.vertical

  if (items.length <= 1) {
    return items[0] ?? null
  }

  return (
    <div className={cn(styles.group, groupDirection, className)} role="group" {...props}>
      {items}
    </div>
  )
}

상세 설명

주요 props
childrendirectionclassName
  • direction prop을 CSS module 클래스에 매핑해 배치 방향을 결정합니다.
  • role="group"을 부여해 여러 체크박스의 묶음임을 보조기술에 전달합니다.
  • 자식이 1개 이하이면 그룹 래퍼를 생략합니다.

사용 가이드 JSX

pages/CheckboxGuide.jsx
import Checkbox from '@/components/Checkbox/Checkbox'
import CheckboxGroup from '@/components/Checkbox/CheckboxGroup'

export default function CheckboxGuide() {
  return (
    <section className="flex flex-col gap-8 p-6" style={{ backgroundColor: '#f0f0f0' }}>
      <h2 className="text-title-s">Checkbox</h2>

      <div className="flex flex-col gap-3">
        <h3 className="text-body-s">default</h3>
        <div className="flex flex-wrap items-center gap-4">
          <Checkbox>Label</Checkbox>
          <Checkbox defaultChecked>Label</Checkbox>
          <Checkbox disabled>Label</Checkbox>
          <Checkbox defaultChecked disabled>Label</Checkbox>
        </div>
      </div>

      <div className="flex flex-col gap-3">
        <h3 className="text-body-s">horizontal group</h3>
        <CheckboxGroup direction="horizontal">
          <Checkbox name="checkbox-horizontal" value="1">Label</Checkbox>
          <Checkbox name="checkbox-horizontal" value="2" defaultChecked>Label</Checkbox>
          <Checkbox name="checkbox-horizontal" value="3">Label</Checkbox>
        </CheckboxGroup>
      </div>

      <div className="flex flex-col gap-3">
        <h3 className="text-body-s">vertical group</h3>
        <CheckboxGroup direction="vertical" className="w-64">
          <Checkbox name="checkbox-vertical" value="1" style={{ width: 256 }}>Label</Checkbox>
          <Checkbox name="checkbox-vertical" value="2" defaultChecked style={{ width: 256 }}>Label</Checkbox>
          <Checkbox name="checkbox-vertical" value="3" style={{ width: 256 }}>Label</Checkbox>
        </CheckboxGroup>
      </div>

      <div className="flex flex-col gap-3">
        <h3 className="text-body-s">right text</h3>
        <CheckboxGroup direction="vertical" className="w-64">
          <Checkbox rightText="000+" style={{ width: 256 }}>Label</Checkbox>
          <Checkbox rightText="000+" defaultChecked style={{ width: 256 }}>Label</Checkbox>
          <Checkbox rightText="000+" disabled style={{ width: 256 }}>Label</Checkbox>
        </CheckboxGroup>
      </div>
    </section>
  )
}

Header

2개 JSX 컴포넌트가 있습니다.

Header

HeaderSub

서브 화면에서 쓰는 제목형 헤더입니다. 좌측, 제목, 액션, 우측 영역을 분리합니다.

Top

컴포넌트 코드

components/Header/HeaderSub.jsx

import { cn } from '@/utils/cn'
import styles from './HeaderSub.module.css'

export default function HeaderSub({
  title,
  left,
  action,
  right,
  className,
  titleClassName,
  ...props
}) {
  return (
    <header className={cn(styles.header, className)} {...props}>
      <div className={styles.left}>
        {left ?? <span className={styles.iconPlaceholder} aria-hidden="true" />}
        {title && <strong className={cn(styles.title, titleClassName)}>{title}</strong>}
      </div>

      <div className={styles.right}>
        {action}
        {right ?? (
          <>
            <span className={styles.iconPlaceholder} aria-hidden="true" />
            <span className={styles.iconPlaceholder} aria-hidden="true" />
          </>
        )}
      </div>
    </header>
  )
}

상세 설명

주요 props
titleleftactionrightclassNametitleClassName
  • left prop이 없으면 placeholder를 넣어 제목 시작 위치를 안정적으로 맞춥니다.
  • action은 우측 영역 앞쪽에 추가되는 보조 액션이고, right는 아이콘 묶음 같은 커스텀 우측 영역입니다.
  • titleClassName으로 제목 스타일만 별도로 확장할 수 있습니다.

사용 가이드 JSX

pages/HeaderGuide.jsx
import Header from '@/components/Header/Header'
import HeaderSub from '@/components/Header/HeaderSub'

function SmallButton({ children }) {
  return (
    <button
      className="h-8 rounded-full border border-gray-900 bg-white px-4 text-caption-s"
      type="button"
    >
      {children}
    </button>
  )
}

export default function HeaderGuide() {
  return (
    <section className="flex flex-col gap-8 p-6" style={{ backgroundColor: '#f0f0f0' }}>
      <h2 className="text-title-s">Header</h2>

      <div className="flex max-w-md flex-col gap-5">
        <div className="flex flex-col gap-3">
          <h3 className="text-body-s">main header</h3>
          <Header />
        </div>

        <div className="flex flex-col gap-3">
          <h3 className="text-body-s">sub header</h3>
          <HeaderSub title={'\uc81c\ubaa9'} action={<SmallButton>{'\ubc84\ud2bc\uba85'}</SmallButton>} />
        </div>
      </div>
    </section>
  )
}

icons

3개 JSX 컴포넌트가 있습니다.

icons

FilterIcon

필터/슬라이더 형태의 SVG 아이콘 컴포넌트입니다.

Top

컴포넌트 코드

components/icons/FilterIcon.jsx

export default function FilterIcon({ size = 32, stroke, fill}){
    return(
        <svg width={size} height={size} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
            <path d="M5.33337 8H26.6667" stroke={stroke ?? '#222'} stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
            <path d="M5.33337 16H26.6667" stroke={stroke ?? '#222'} stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
            <path d="M5.33337 24H26.6667" stroke={stroke ?? '#222'} stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
            <circle cx="10.6667" cy="8.00008" r="2.33333" fill={fill ?? '#fff'} stroke={stroke ?? '#222'} stroke-width="2"/>
            <circle cx="21.3333" cy="16.0001" r="2.33333" fill={fill ?? '#fff'} stroke={stroke ?? '#EF3B24'} stroke-width="2"/>
            <circle cx="10.6667" cy="24.0001" r="2.33333" fill={fill ?? '#fff'} stroke={stroke ?? '#222'} stroke-width="2"/>
        </svg>
    )
}

상세 설명

주요 props
size
  • size, stroke, fill prop으로 버튼 상태에 따라 크기와 색상을 바꿀 수 있습니다.
  • 현재 JSX에는 stroke-width처럼 React 표준 camelCase가 아닌 속성이 있어 콘솔 경고가 날 수 있습니다.

icons

TextEditIcon

텍스트 편집/변환을 상징하는 SVG 아이콘 컴포넌트입니다.

Top

컴포넌트 코드

components/icons/TextEditIcon.jsx

export default function TextEditIcon({ size = 24, stroke }) {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      xmlns="http://www.w3.org/2000/svg"
    >
      <path d="M2.83325 14.75C2.83325 18.2975 5.70242 21.1667 9.24992 21.1667L8.28743 19.5625" stroke={stroke ?? '#222222'} strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>
      <path d="M21.1665 9.24992C21.1665 5.70242 18.2974 2.83325 14.7499 2.83325L15.7124 4.43742" stroke={stroke ?? '#222222'} strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>
      <path d="M7.72339 10.083V9.2488C7.72339 8.72172 8.14964 8.30005 8.67214 8.30005H15.3271C15.8542 8.30005 16.2759 8.7263 16.2759 9.2488V10.083" stroke={stroke ?? '#EF3B24'} strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>
      <path d="M12 16.2941V8.68115" stroke={stroke ?? '#EF3B24'} strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>
      <path d="M10.1941 16.2942H13.8058" stroke={stroke ?? '#EF3B24'} strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"/>
    </svg>
  )
}

상세 설명

주요 props
size
  • size와 stroke를 받아 Button, ButtonIcon에서 재사용할 수 있습니다.
  • stroke가 없으면 path별 기본 색상을 사용합니다.

icons

UserAddIcon

사용자 추가를 표현하는 SVG 아이콘 컴포넌트입니다.

Top

컴포넌트 코드

components/icons/UserAddIcon.jsx

export default function UserAddIcon({ size = 24, stroke }) {
  return (
    <svg
      width={size}
      height={size}
      viewBox="0 0 24 24"
      fill="none"
      xmlns="http://www.w3.org/2000/svg"
    >
      <path
        d="M12 12C14.2091 12 16 10.2091 16 8C16 5.79086 14.2091 4 12 4C9.79086 4 8 5.79086 8 8C8 10.2091 9.79086 12 12 12Z"
        stroke={stroke ?? '#222222'}
        strokeWidth="1.6"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
      <path
        d="M4 21C4 17.13 7.6598 14 12.1656 14C12.4473 14 12.7256 14.0122 13 14.0361"
        stroke={stroke ?? '#222222'}
        strokeWidth="1.6"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
      <path
        d="M22 18C22 18.32 21.96 18.63 21.88 18.93C21.79 19.33 21.63 19.72 21.42 20.06C20.73 21.22 19.46 22 18 22C16.97 22 16.04 21.61 15.34 20.97C15.04 20.71 14.78 20.4 14.58 20.06C14.21 19.46 14 18.75 14 18C14 16.92 14.43 15.93 15.13 15.21C15.86 14.46 16.88 14 18 14C19.18 14 20.25 14.51 20.97 15.33C21.61 16.04 22 16.98 22 18Z"
        stroke={stroke ?? '#222222'}
        strokeWidth="1.6"
        strokeMiterlimit="10"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
      <path
        d="M19.49 17.98H16.51"
        stroke={stroke ?? '#EF3B24'}
        strokeWidth="1.6"
        strokeMiterlimit="10"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
      <path
        d="M18 16.52V19.51"
        stroke={stroke ?? '#EF3B24'}
        strokeWidth="1.6"
        strokeMiterlimit="10"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  )
}

상세 설명

주요 props
size
  • size와 stroke를 받아 아이콘 버튼 안에서 상태별 색상 변경에 대응합니다.
  • 사람 실루엣과 더하기 표시를 각각 path로 구성합니다.

사용 가이드 JSX

pages/IconGuide.jsx
import TextEditIcon from '@/components/icons/TextEditIcon'
import UserAddIcon from '@/components/icons/UserAddIcon'
import FilterIcon from '@/components/icons/FilterIcon'

const iconSections = [
  {
    title: 'TextEditIcon',
    examples: [
      {
        label: '기본',
        icon: <TextEditIcon />,
        code: '<TextEditIcon />',
      },
      {
        label: 'size 32px',
        icon: <TextEditIcon size={32} />,
        code: '<TextEditIcon size={32} />',
      },
      {
        label: 'stroke #fff active',
        icon: (
          <div className="flex h-10 w-10 items-center justify-center rounded-md" style={{backgroundColor:'#222'}}>
            <TextEditIcon stroke="#ffffff" />
          </div>
        ),
        code: '<TextEditIcon stroke="#ffffff" />',
      },
      {
        label: 'stroke #fff inactive',
        icon: (
          <div className="flex h-10 w-10 items-center justify-center rounded-md" style={{backgroundColor:'#E0E0E0'}}>
            <TextEditIcon stroke="#ffffff" />
          </div>
        ),
        code: '<TextEditIcon stroke="#ffffff" />',
      },
    ],
  },
  {
    title: 'UserAddIcon',
    examples: [
      {
        label: '기본',
        icon: <UserAddIcon />,
        code: '<UserAddIcon />',
      },
      {
        label: 'size 32px',
        icon: <UserAddIcon size={32} />,
        code: '<UserAddIcon size={32} />',
      },
      {
        label: 'stroke #fff active',
        icon: (
          <div className="flex h-10 w-10 items-center justify-center rounded-md" style={{backgroundColor:'#222'}}>
            <UserAddIcon stroke="#ffffff" />
          </div>
        ),
        code: '<UserAddIcon stroke="#ffffff" />',
      },
      {
        label: 'stroke #fff inactive',
        icon: (
          <div className="flex h-10 w-10 items-center justify-center rounded-md" style={{backgroundColor:'#e0e0e0'}}>
            <UserAddIcon stroke="#ffffff" />
          </div>
        ),
        code: '<UserAddIcon stroke="#ffffff" />',
      },
    ],
  },
  {
    title: 'UserAddIcon',
    examples: [
      {
        label: '기본',
        icon: <FilterIcon />,
        code: '<FilterIcon />',
      },
      {
        label: 'stroke #fff active',
        icon: (
          <div className="flex h-10 w-10 items-center justify-center rounded-md" style={{backgroundColor:'#222'}}>
            <FilterIcon stroke="#ffffff" fill="#222" />
          </div>
        ),
        code: '<FilterIcon stroke="#ffffff" fill="#222" />',
      },
      {
        label: 'stroke #fff inactive',
        icon: (
          <div className="flex h-10 w-10 items-center justify-center rounded-md" style={{backgroundColor:'#e0e0e0'}}>
            <FilterIcon stroke="#ffffff" fill="#e0e0e0" />
          </div>
        ),
        code: '<FilterIcon stroke="#ffffff" fill="#e0e0e0" />',
      },
    ],
  },
]

export default function IconGuide() {
  return (
    <section className="flex flex-col gap-8 p-6">
      <div className="flex flex-col gap-2">
        <h2 className="text-title-s">Icon</h2>
        <p className="text-caption-m text-gray-600">size 미지정 시 기본 24px</p>
      </div>

      {iconSections.map(({ title, examples }) => (
        <div key={title} className="flex flex-col gap-3">
          <h3 className="text-body-s">{title}</h3>
          <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
            {examples.map(({ label, icon, code }) => (
              <div
                key={label}
                className="flex min-h-32 flex-col justify-between gap-4 rounded-lg border border-gray-200 p-4"
              >
                <div className="flex h-12 items-center justify-center">{icon}</div>
                <div className="flex flex-col gap-1">
                  <strong className="text-body-s">{label}</strong>
                  <code className="break-all text-label-l text-gray-600">{code}</code>
                </div>
              </div>
            ))}
          </div>
        </div>
      ))}
    </section>
  )
}

Message

1개 JSX 컴포넌트가 있습니다.

Message

Message

현재 파일이 비어 있어 구현된 렌더링 로직이 없습니다.

Top

컴포넌트 코드

components/Message/Message.jsx

현재 파일 내용이 없습니다.

상세 설명

분석할 props가 없거나 아직 export된 컴포넌트가 없습니다.

  • 스터디 관점에서는 컴포넌트 설계 예정 파일 또는 삭제 후보인지 확인해야 합니다.
  • 사용처가 생기기 전까지는 export가 없어 import 시 오류가 발생할 수 있습니다.

Modal

4개 JSX 컴포넌트가 있습니다.

Modal

BottomModal

현재 파일이 비어 있어 구현된 렌더링 로직이 없습니다.

Top

컴포넌트 코드

components/Modal/BottomModal.jsx

현재 파일 내용이 없습니다.

상세 설명

분석할 props가 없거나 아직 export된 컴포넌트가 없습니다.

  • BottomSheet가 실제 하단 모달 UI를 구현하고 있으므로 BottomModal의 역할을 별도로 정해야 합니다.
  • 비어 있는 파일을 import하면 기본 export가 없어 빌드 오류가 날 수 있습니다.

Modal

BottomSheet

모바일 하단에서 올라오는 시트형 dialog UI입니다.

Top

컴포넌트 코드

components/Modal/BottomSheet.jsx

import Button from '@/components/Button/Button'
import { cn } from '@/utils/cn'
import styles from './BottomSheet.module.css'

function CheckIcon() {
  return (
    <svg viewBox="0 0 24 24" aria-hidden="true">
      <path d="m6 12 4 4 8-8" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
    </svg>
  )
}

function CloseIcon() {
  return (
    <svg viewBox="0 0 24 24" aria-hidden="true">
      <path d="m6 6 12 12M18 6 6 18" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" />
    </svg>
  )
}

export default function BottomSheet({
  title = '\uc8fc\uc694\ubb38\uad6c',
  subText = '\ubd80\uac00\uc124\uba85',
  items = [],
  buttonLabel = '\ubc84\ud2bc\uba85',
  onButtonClick,
  onClose,
  className,
  contentClassName,
  buttonClassName,
  ...props
}) {
  return (
    <section className={cn(styles.sheet, className)} role="dialog" aria-modal="true" {...props}>
      <button className={styles.closeButton} type="button" aria-label="Close" onClick={onClose}>
        <CloseIcon />
      </button>

      <div className={cn(styles.content, contentClassName)}>
        <div className={styles.header}>
          <strong className={styles.title}>{title}</strong>
          {subText && <p className={styles.subText}>{subText}</p>}
        </div>

        {items.length > 0 && (
          <ul className={styles.list}>
            {items.map((item, index) => (
              <li className={styles.item} key={`${item.label}-${index}`}>
                <span className={styles.check}>
                  {item.icon ?? <CheckIcon />}
                </span>
                <span className={styles.itemLabel}>{item.label}</span>
                {item.rightText && <span className={styles.itemRight}>{item.rightText}</span>}
              </li>
            ))}
          </ul>
        )}

        {buttonLabel && (
          <Button className={cn(styles.button, buttonClassName)} variant="gradation" size="xxl" fullWidth onClick={onButtonClick}>
            {buttonLabel}
          </Button>
        )}
      </div>
    </section>
  )
}

상세 설명

주요 props
titlesubTextitemsbuttonLabelonButtonClickonCloseclassNamecontentClassNamebuttonClassName
  • role="dialog"와 aria-modal="true"를 부여해 모달 성격을 명시합니다.
  • 닫기 버튼, 제목/보조문구, 체크 아이콘 리스트, 하단 gradation CTA 버튼으로 구성됩니다.
  • items 배열은 label, rightText, icon을 받을 수 있어 안내 목록이나 선택 결과 요약에 적합합니다.
  • buttonLabel을 비우면 하단 CTA 버튼을 숨길 수 있습니다.

Modal

FullModal

현재 파일이 비어 있어 구현된 렌더링 로직이 없습니다.

Top

컴포넌트 코드

components/Modal/FullModal.jsx

현재 파일 내용이 없습니다.

상세 설명

분석할 props가 없거나 아직 export된 컴포넌트가 없습니다.

  • 전체 화면 모달로 확장할 예정이라면 HeaderSub, ButtonGroup 같은 기존 컴포넌트를 조합하는 방식이 자연스럽습니다.
  • 현재 상태에서는 import/export 계약이 없으므로 사용 전 구현이 필요합니다.

사용 가이드 JSX

pages/ModalGuide.jsx
import { useState } from 'react'
import Button from '@/components/Button/Button'
import Modal from '@/components/Modal/Modal'

const modalTypes = ['alert', 'info', 'confirm', 'error']

const modalLabels = {
    alert: 'Alert',
    info: 'Info',
    confirm: 'Confirm',
    error: 'Error',
}

export default function ModalGuide() {
    const [openType, setOpenType] = useState(null)

    function closeModal() {
        setOpenType(null)
    }

    const isConfirm = openType === 'confirm'
    const modalButtons = isConfirm
        ? [
            { label: '\ubc84\ud2bc\uba85', variant: 'outlineLight', onClick: closeModal },
            { label: '\ubc84\ud2bc\uba85', variant: 'secondary', onClick: closeModal },
        ]
        : [{ label: '\ubc84\ud2bc\uba85', onClick: closeModal }]

    return (
        <section className="flex flex-col gap-8 p-6" style={{ backgroundColor: '#f0f0f0' }}>
            <h2 className="text-title-s">Modal</h2>

            <div className="flex flex-col gap-4">
                <h3 className="text-body-s">open modal</h3>
                <div className="flex flex-wrap gap-3">
                    {modalTypes.map((type) => (
                        <Button key={type} variant="secondary" size="md" onClick={() => setOpenType(type)}>
                            {modalLabels[type]}
                        </Button>
                    ))}
                </div>
            </div>



            {openType && (
                <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-6" onClick={closeModal}>
                    <div onClick={(event) => event.stopPropagation()}>
                        <Modal
                            type={openType}
                            mainText={'\uc8fc\uc694\ubb38\uad6c'}
                            subText={'\ubd80\uac00\uc124\uba85'}
                            buttons={modalButtons}
                            buttonRatio="35-65"
                        />
                    </div>
                </div>
            )}
        </section>
    )
}
pages/BottomModalGuide.jsx
import { useState } from 'react'
import Button from '@/components/Button/Button'
import BottomSheet from '@/components/Modal/BottomSheet'

const items = [
  { label: '\ubb38\uad6c', rightText: '000+' },
  { label: '\ubb38\uad6c', rightText: '000+' },
]

export default function BottomModalGuide() {
  const [open, setOpen] = useState(false)

  return (
    <section className="flex flex-col gap-8 p-6" style={{ backgroundColor: '#f0f0f0' }}>
      <h2 className="text-title-s">Bottom Sheet</h2>

      <div className="flex flex-col gap-3">
        <h3 className="text-body-s">bottom sheet</h3>
        <Button variant="secondary" size="md" onClick={() => setOpen(true)}>
          Open Bottom Sheet
        </Button>
      </div>

      {open && (
        <div className="fixed inset-0 z-50 flex items-end justify-center bg-black/40" onClick={() => setOpen(false)}>
          <div onClick={(event) => event.stopPropagation()}>
            <BottomSheet items={items} onClose={() => setOpen(false)} onButtonClick={() => setOpen(false)} />
          </div>
        </div>
      )}
    </section>
  )
}
pages/FullModalGuide.jsx

현재 파일 내용이 없습니다.

Radio

2개 JSX 컴포넌트가 있습니다.

Radio

Radio

기본 radio input을 커스텀 control, 라벨, 우측 보조영역과 함께 렌더링합니다.

Top

컴포넌트 코드

components/Radio/Radio.jsx

import { cn } from '@/utils/cn'
import styles from './Radio.module.css'

export default function Radio({
  children,
  label,
  rightText,
  rightContent,
  rightTone = 'primary',
  className,
  controlClassName,
  textClassName,
  rightClassName,
  ...props
}) {
  const radioLabel = label ?? children
  const radioRightContent = rightContent ?? rightText

  return (
    <label className={cn(styles.radio, className)}>
      <input {...props} className={styles.input} type="radio" />
      <span className={cn(styles.control, controlClassName)} aria-hidden="true" />
      {radioLabel && <span className={cn(styles.text, textClassName)}>{radioLabel}</span>}
      {radioRightContent && (
        <span className={cn(styles.right, styles[rightTone], rightClassName)}>{radioRightContent}</span>
      )}
    </label>
  )
}

상세 설명

주요 props
childrenlabelrightTextrightContentrightToneclassNamecontrolClassNametextClassNamerightClassName
  • Checkbox와 구조가 거의 같지만 input type이 radio입니다.
  • 동일 name을 가진 Radio들을 함께 쓰면 브라우저 기본 단일 선택 동작을 사용할 수 있습니다.
  • rightContent에는 가격, 상태, 보조 버튼 등 JSX를 넣을 수 있습니다.

Radio

RadioGroup

Radio들을 vertical 또는 horizontal 방향으로 묶는 그룹 컴포넌트입니다.

Top

컴포넌트 코드

components/Radio/RadioGroup.jsx

import { Children } from 'react'
import { cn } from '@/utils/cn'
import styles from './RadioGroup.module.css'

const RADIO_GROUP_DIRECTIONS = {
  horizontal: styles.horizontal,
  vertical: styles.vertical,
}

export default function RadioGroup({
  children,
  direction = 'vertical',
  className,
  ...props
}) {
  const items = Children.toArray(children).filter(Boolean)
  const groupDirection = RADIO_GROUP_DIRECTIONS[direction] ?? RADIO_GROUP_DIRECTIONS.vertical

  if (items.length <= 1) {
    return items[0] ?? null
  }

  return (
    <div className={cn(styles.group, groupDirection, className)} role="radiogroup" {...props}>
      {items}
    </div>
  )
}

상세 설명

주요 props
childrendirectionclassName
  • role="radiogroup"을 사용해 라디오 선택 묶음임을 표현합니다.
  • direction이 잘못 들어오면 vertical 스타일로 대체됩니다.
  • 자식이 1개 이하이면 래퍼를 생략해 DOM을 단순하게 유지합니다.

사용 가이드 JSX

pages/RadioGuide.jsx
import Radio from '@/components/Radio/Radio'
import RadioGroup from '@/components/Radio/RadioGroup'

export default function RadioGuide() {
  return (
    <section className="flex flex-col gap-8 p-6" style={{ backgroundColor: '#f0f0f0' }}>
      <h2 className="text-title-s">Radio</h2>

      <div className="flex flex-col gap-3">
        <h3 className="text-body-s">default</h3>
        <div className="flex flex-wrap items-center gap-4">
          <Radio name="radio-default" value="1">Label</Radio>
          <Radio name="radio-default" value="2" defaultChecked>Label</Radio>
          <Radio name="radio-disabled" value="3" disabled>Label</Radio>
          <Radio name="radio-disabled" value="4" defaultChecked disabled>Label</Radio>
        </div>
      </div>

      <div className="flex flex-col gap-3">
        <h3 className="text-body-s">horizontal group</h3>
        <RadioGroup direction="horizontal">
          <Radio name="radio-horizontal" value="1">Label</Radio>
          <Radio name="radio-horizontal" value="2" defaultChecked>Label</Radio>
          <Radio name="radio-horizontal" value="3">Label</Radio>
        </RadioGroup>
      </div>

      <div className="flex flex-col gap-3">
        <h3 className="text-body-s">vertical group</h3>
        <RadioGroup direction="vertical" className="w-64">
          <Radio name="radio-vertical" value="1" style={{ width: 256 }}>Label</Radio>
          <Radio name="radio-vertical" value="2" defaultChecked style={{ width: 256 }}>Label</Radio>
          <Radio name="radio-vertical" value="3" style={{ width: 256 }}>Label</Radio>
        </RadioGroup>
      </div>

      <div className="flex flex-col gap-3">
        <h3 className="text-body-s">right text</h3>
        <RadioGroup direction="vertical" className="w-64">
          <Radio name="radio-right" value="1" rightText="000+" style={{ width: 256 }}>Label</Radio>
          <Radio name="radio-right" value="2" rightText="000+" defaultChecked style={{ width: 256 }}>Label</Radio>
          <Radio name="radio-right-disabled" value="3" rightText="000+" disabled style={{ width: 256 }}>Label</Radio>
        </RadioGroup>
      </div>
    </section>
  )
}

Switch

1개 JSX 컴포넌트가 있습니다.

Switch

Switch

checkbox input을 switch 역할로 노출하는 토글 컴포넌트입니다.

Top

컴포넌트 코드

components/Switch/Switch.jsx

import { cn } from '@/utils/cn'
import styles from './Switch.module.css'

export default function Switch({
  children,
  label,
  className,
  trackClassName,
  textClassName,
  ...props
}) {
  const switchLabel = label ?? children

  return (
    <label className={cn(styles.switch, className)}>
      <input {...props} className={styles.input} type="checkbox" role="switch" />
      <span className={cn(styles.track, trackClassName)} aria-hidden="true">
        <span className={styles.thumb} />
      </span>
      {switchLabel && <span className={cn(styles.text, textClassName)}>{switchLabel}</span>}
    </label>
  )
}

상세 설명

주요 props
childrenlabelclassNametrackClassNametextClassName
  • input type은 checkbox이고 role="switch"를 추가해 의미를 보강합니다.
  • track과 thumb을 별도 span으로 렌더링해 CSS에서 on/off 시각 상태를 제어합니다.
  • label 또는 children을 우측 텍스트로 표시하며, checked/onChange는 ...props로 전달합니다.

사용 가이드 JSX

pages/SwitchGuide.jsx
import Switch from '@/components/Switch/Switch'

export default function SwitchGuide() {
  return (
    <section className="flex flex-col gap-8 p-6" style={{ backgroundColor: '#f0f0f0' }}>
      <h2 className="text-title-s">Switch</h2>

      <div className="flex flex-col gap-3">
        <h3 className="text-body-s">default</h3>
        <div className="flex flex-wrap items-center gap-4">
          <Switch aria-label="Off" />
          <Switch aria-label="On" defaultChecked />
          <Switch aria-label="Disabled off" disabled />
          <Switch aria-label="Disabled on" defaultChecked disabled />
        </div>
      </div>

      <div className="flex flex-col gap-3">
        <h3 className="text-body-s">with label</h3>
        <div className="flex flex-col gap-3">
          <Switch name="switch-label" value="1">Label</Switch>
          <Switch name="switch-label" value="2" defaultChecked>Label</Switch>
          <Switch name="switch-label" value="3" disabled>Label</Switch>
        </div>
      </div>
    </section>
  )
}

Tab

1개 JSX 컴포넌트가 있습니다.

Tab

Tab

버튼형 탭과 링크형 내비게이션 탭을 하나의 API로 제공하는 컴포넌트입니다.

Top

컴포넌트 코드

components/Tab/Tab.jsx

import { useState } from 'react'
import { cn } from '@/utils/cn'
import styles from './Tab.module.css'

function getItemValue(item, index) {
  return item.value ?? item.href ?? String(index)
}

export default function Tab({
  items = [],
  type = 'button',
  value,
  defaultValue,
  onChange,
  className,
  itemClassName,
  activeItemClassName,
  ...props
}) {
  const firstValue = items[0] ? getItemValue(items[0], 0) : undefined
  const [internalValue, setInternalValue] = useState(defaultValue ?? firstValue)
  const selectedValue = value ?? internalValue
  const isLinkType = type === 'link'

  function handleChange(nextValue, item, event) {
    if (!isLinkType) {
      setInternalValue(nextValue)
    }

    onChange?.(nextValue, item, event)
  }

  if (!items.length) return null

  return (
    <div className={cn(styles.tab, className)} role={isLinkType ? 'navigation' : 'tablist'} {...props}>
      {items.map((item, index) => {
        const itemValue = getItemValue(item, index)
        const isActive = itemValue === selectedValue || item.active
        const classNameValue = cn(styles.item, isActive && styles.active, itemClassName, isActive && activeItemClassName)

        if (isLinkType) {
          return (
            <a
              key={itemValue}
              className={classNameValue}
              href={item.href}
              aria-current={isActive ? 'page' : undefined}
              target={item.target}
              rel={item.rel}
              onClick={(event) => handleChange(itemValue, item, event)}
            >
              {item.label}
            </a>
          )
        }

        return (
          <button
            key={itemValue}
            className={classNameValue}
            type="button"
            role="tab"
            aria-selected={isActive}
            disabled={item.disabled}
            onClick={(event) => handleChange(itemValue, item, event)}
          >
            {item.label}
          </button>
        )
      })}
    </div>
  )
}

상세 설명

주요 props
itemstypevaluedefaultValueonChangeclassNameitemClassNameactiveItemClassName
  • items 배열의 value, href, index 순서로 각 탭의 고유 값을 결정합니다.
  • value가 있으면 controlled, 없으면 defaultValue 또는 첫 번째 item 값으로 uncontrolled 상태를 관리합니다.
  • type="link"이면 a 태그와 aria-current를 사용하고, 기본 button 타입이면 role="tab"과 aria-selected를 사용합니다.
  • item.active가 true이면 selectedValue와 무관하게 활성 스타일을 강제할 수 있습니다.

사용 가이드 JSX

pages/TabGuide.jsx
import { useState } from 'react'
import Tab from '@/components/Tab/Tab'

const tabs = [
  { label: '버튼명', value: 'first' },
  { label: '버튼명', value: 'second' },
]

const links = [
  { label: '버튼명', value: 'first', href: '#tab-link-first' },
  { label: '버튼명', value: 'second', href: '#tab-link-second' },
]

export default function TabGuide() {
  const [tabValue, setTabValue] = useState('first')

  return (
    <section className="flex flex-col gap-8 p-6" style={{ backgroundColor: '#f0f0f0' }}>
      <h2 className="text-title-s">Tab</h2>

      <div className="grid max-w-5xl grid-cols-1 gap-6 md:grid-cols-2">
        <div className="flex flex-col gap-3 bg-white p-8">
          <h3 className="text-body-s">switch type</h3>
          <Tab items={tabs} value={tabValue} onChange={setTabValue} />
          <div className="text-caption-l">{tabValue}</div>
        </div>

        <div className="flex flex-col gap-3 bg-white p-8">
          <h3 className="text-body-s">link type</h3>
          <Tab items={links} type="link" defaultValue="first" />
        </div>
      </div>
    </section>
  )
}

Textfield

5개 JSX 컴포넌트가 있습니다.

Textfield

Input

라벨, 우측 헤더 버튼, 도움말/에러 메시지를 포함한 단일 텍스트 입력 필드입니다.

Top

컴포넌트 코드

components/Textfield/Input.jsx

import { cn } from '@/utils/cn'
import styles from './Input.module.css'

export default function Input({
  label,
  buttonLabel,
  onButtonClick,
  helperText,
  errorText,
  invalid = Boolean(errorText),
  disabled = false,
  className,
  fieldClassName,
  inputClassName,
  labelClassName,
  buttonClassName,
  helperClassName,
  type = 'text',
  ...props
}) {
  const message = errorText ?? helperText
  const hasButton = Boolean(buttonLabel)

  return (
    <div className={cn(styles.field, disabled && styles.disabled, invalid && styles.invalid, className)}>
      {(label || hasButton) && (
        <div className={styles.header}>
          {label && <span className={cn(styles.label, labelClassName)}>{label}</span>}
          {hasButton && (
            <button
              className={cn(styles.button, buttonClassName)}
              type="button"
              disabled={disabled}
              onClick={onButtonClick}
            >
              {buttonLabel}
            </button>
          )}
        </div>
      )}

      <div className={cn(styles.box, fieldClassName)}>
        <input {...props} className={cn(styles.input, inputClassName)} type={type} disabled={disabled} />
      </div>

      {message && (
        <span className={cn(styles.message, helperClassName)}>
          {message}
        </span>
      )}
    </div>
  )
}

상세 설명

주요 props
labelbuttonLabelonButtonClickhelperTexterrorTextinvaliddisabledclassNamefieldClassNameinputClassNamelabelClassNamebuttonClassNamehelperClassNametype
  • errorText가 있으면 기본 invalid가 true가 되고, helperText보다 errorText를 우선 표시합니다.
  • label 또는 buttonLabel이 있을 때만 header 영역을 렌더링합니다.
  • type, value, defaultValue, placeholder, onChange 같은 input 기본 prop은 ...props로 전달됩니다.
  • fieldClassName, inputClassName 등 세부 className prop으로 외부에서 부분 스타일을 확장할 수 있습니다.

Textfield

InputArea

Input과 같은 필드 구조를 textarea에 적용한 장문 입력 컴포넌트입니다.

Top

컴포넌트 코드

components/Textfield/InputArea.jsx

import { cn } from '@/utils/cn'
import styles from './InputArea.module.css'

export default function InputArea({
  label,
  buttonLabel,
  onButtonClick,
  helperText,
  errorText,
  invalid = Boolean(errorText),
  disabled = false,
  className,
  fieldClassName,
  textareaClassName,
  labelClassName,
  buttonClassName,
  helperClassName,
  rows = 5,
  ...props
}) {
  const message = errorText ?? helperText
  const hasButton = Boolean(buttonLabel)

  return (
    <div className={cn(styles.field, disabled && styles.disabled, invalid && styles.invalid, className)}>
      {(label || hasButton) && (
        <div className={styles.header}>
          {label && <span className={cn(styles.label, labelClassName)}>{label}</span>}
          {hasButton && (
            <button
              className={cn(styles.button, buttonClassName)}
              type="button"
              disabled={disabled}
              onClick={onButtonClick}
            >
              {buttonLabel}
            </button>
          )}
        </div>
      )}

      <div className={cn(styles.box, fieldClassName)}>
        <textarea {...props} className={cn(styles.textarea, textareaClassName)} rows={rows} disabled={disabled} />
      </div>

      {message && (
        <span className={cn(styles.message, helperClassName)}>
          {message}
        </span>
      )}
    </div>
  )
}

상세 설명

주요 props
labelbuttonLabelonButtonClickhelperTexterrorTextinvaliddisabledclassNamefieldClassNametextareaClassNamelabelClassNamebuttonClassNamehelperClassNamerows
  • rows 기본값은 5이며 textareaClassName으로 textarea 자체 스타일을 확장합니다.
  • helperText/errorText/invalid/disabled 처리 방식은 Input과 동일합니다.
  • textarea 기본 prop은 ...props로 전달되므로 value, maxLength, onChange 등을 그대로 사용할 수 있습니다.

Textfield

InputCard

카드번호처럼 4개 구간으로 나뉜 숫자 입력 UI입니다.

Top

컴포넌트 코드

components/Textfield/InputCard.jsx

import { cn } from '@/utils/cn'
import styles from './InputCard.module.css'

const CARD_PART_COUNT = 4

function toParts(value, fallback) {
  if (Array.isArray(value)) {
    return Array.from({ length: CARD_PART_COUNT }, (_, index) => value[index] ?? '')
  }

  return Array.from({ length: CARD_PART_COUNT }, () => fallback)
}

export default function InputCard({
  label,
  buttonLabel,
  onButtonClick,
  helperText,
  errorText,
  invalid = Boolean(errorText),
  disabled = false,
  values,
  defaultValues,
  placeholders,
  names,
  maxLength = 4,
  className,
  fieldClassName,
  inputClassName,
  labelClassName,
  buttonClassName,
  helperClassName,
  onChange,
}) {
  const message = errorText ?? helperText
  const hasButton = Boolean(buttonLabel)
  const inputValues = Array.isArray(values) ? toParts(values, '') : null
  const inputDefaultValues = toParts(defaultValues, '')
  const inputPlaceholders = toParts(placeholders, '문구')
  const inputNames = toParts(names, undefined)

  return (
    <div className={cn(styles.field, disabled && styles.disabled, invalid && styles.invalid, className)}>
      {(label || hasButton) && (
        <div className={styles.header}>
          {label && <span className={cn(styles.label, labelClassName)}>{label}</span>}
          {hasButton && (
            <button
              className={cn(styles.button, buttonClassName)}
              type="button"
              disabled={disabled}
              onClick={onButtonClick}
            >
              {buttonLabel}
            </button>
          )}
        </div>
      )}

      <div className={cn(styles.box, fieldClassName)}>
        {Array.from({ length: CARD_PART_COUNT }, (_, index) => (
          <div className={styles.part} key={index}>
            <input
              className={cn(styles.input, inputClassName)}
              defaultValue={inputValues ? undefined : inputDefaultValues[index]}
              disabled={disabled}
              inputMode="numeric"
              maxLength={maxLength}
              name={inputNames[index]}
              placeholder={inputPlaceholders[index]}
              type="text"
              value={inputValues?.[index]}
              onChange={(event) => onChange?.(event, index)}
            />
            {index < CARD_PART_COUNT - 1 && <span className={styles.separator}>-</span>}
          </div>
        ))}
      </div>

      {message && (
        <span className={cn(styles.message, helperClassName)}>
          {message}
        </span>
      )}
    </div>
  )
}

상세 설명

주요 props
labelbuttonLabelonButtonClickhelperTexterrorTextinvaliddisabledvaluesdefaultValuesplaceholdersnamesmaxLengthclassNamefieldClassNameinputClassNamelabelClassNamebuttonClassNamehelperClassNameonChange
  • CARD_PART_COUNT가 4로 고정되어 네 개 input을 렌더링합니다.
  • values 배열을 넘기면 controlled, defaultValues를 넘기면 uncontrolled 초기값 방식으로 동작합니다.
  • placeholders, names도 배열로 받아 각 input에 분배하고 값이 부족하면 fallback으로 채웁니다.
  • 각 input의 onChange는 원본 event와 index를 함께 넘겨 상위에서 어느 칸이 바뀌었는지 알 수 있습니다.

Textfield

InputPrice

금액 입력처럼 입력값 오른쪽에 단위 텍스트를 붙이는 필드 컴포넌트입니다.

Top

컴포넌트 코드

components/Textfield/InputPrice.jsx

import { cn } from '@/utils/cn'
import styles from './InputPrice.module.css'

export default function InputPrice({
  label,
  buttonLabel,
  onButtonClick,
  helperText,
  errorText,
  invalid = Boolean(errorText),
  disabled = false,
  unit = '원',
  className,
  fieldClassName,
  inputClassName,
  unitClassName,
  labelClassName,
  buttonClassName,
  helperClassName,
  type = 'text',
  ...props
}) {
  const message = errorText ?? helperText
  const hasButton = Boolean(buttonLabel)

  return (
    <div className={cn(styles.field, disabled && styles.disabled, invalid && styles.invalid, className)}>
      {(label || hasButton) && (
        <div className={styles.header}>
          {label && <span className={cn(styles.label, labelClassName)}>{label}</span>}
          {hasButton && (
            <button
              className={cn(styles.button, buttonClassName)}
              type="button"
              disabled={disabled}
              onClick={onButtonClick}
            >
              {buttonLabel}
            </button>
          )}
        </div>
      )}

      <div className={cn(styles.box, fieldClassName)}>
        <input
          {...props}
          className={cn(styles.input, inputClassName)}
          type={type}
          inputMode={type === 'text' ? 'numeric' : undefined}
          disabled={disabled}
        />
        {unit && <span className={cn(styles.unit, unitClassName)}>{unit}</span>}
      </div>

      {message && (
        <span className={cn(styles.message, helperClassName)}>
          {message}
        </span>
      )}
    </div>
  )
}

상세 설명

주요 props
labelbuttonLabelonButtonClickhelperTexterrorTextinvaliddisabledunitclassNamefieldClassNameinputClassNameunitClassNamelabelClassNamebuttonClassNamehelperClassNametype
  • unit prop을 input 오른쪽에 표시하고 unitClassName으로 단위 스타일을 확장할 수 있습니다.
  • type이 text일 때 inputMode="numeric"을 넣어 모바일 숫자 키패드가 뜨도록 유도합니다.
  • 라벨, 헤더 버튼, 메시지, invalid/disabled 패턴은 Input과 동일합니다.

Textfield

InputResident

주민등록번호처럼 2개 구간으로 나뉜 숫자 입력 UI입니다.

Top

컴포넌트 코드

components/Textfield/InputResident.jsx

import { cn } from '@/utils/cn'
import styles from './InputResident.module.css'

const RESIDENT_PART_COUNT = 2

function toParts(value, fallback) {
  if (Array.isArray(value)) {
    return Array.from({ length: RESIDENT_PART_COUNT }, (_, index) => value[index] ?? '')
  }

  return Array.from({ length: RESIDENT_PART_COUNT }, () => fallback)
}

export default function InputResident({
  label,
  buttonLabel,
  onButtonClick,
  helperText,
  errorText,
  invalid = Boolean(errorText),
  disabled = false,
  values,
  defaultValues,
  placeholders,
  names,
  maxLengths = [6, 7],
  className,
  fieldClassName,
  inputClassName,
  labelClassName,
  buttonClassName,
  helperClassName,
  onChange,
}) {
  const message = errorText ?? helperText
  const hasButton = Boolean(buttonLabel)
  const inputValues = Array.isArray(values) ? toParts(values, '') : null
  const inputDefaultValues = toParts(defaultValues, '')
  const inputPlaceholders = toParts(placeholders, '문구')
  const inputNames = toParts(names, undefined)
  const inputMaxLengths = toParts(maxLengths, undefined)

  return (
    <div className={cn(styles.field, disabled && styles.disabled, invalid && styles.invalid, className)}>
      {(label || hasButton) && (
        <div className={styles.header}>
          {label && <span className={cn(styles.label, labelClassName)}>{label}</span>}
          {hasButton && (
            <button
              className={cn(styles.button, buttonClassName)}
              type="button"
              disabled={disabled}
              onClick={onButtonClick}
            >
              {buttonLabel}
            </button>
          )}
        </div>
      )}

      <div className={cn(styles.box, fieldClassName)}>
        {Array.from({ length: RESIDENT_PART_COUNT }, (_, index) => (
          <div className={styles.part} key={index}>
            <input
              className={cn(styles.input, inputClassName)}
              defaultValue={inputValues ? undefined : inputDefaultValues[index]}
              disabled={disabled}
              inputMode="numeric"
              maxLength={inputMaxLengths[index]}
              name={inputNames[index]}
              placeholder={inputPlaceholders[index]}
              type="text"
              value={inputValues?.[index]}
              onChange={(event) => onChange?.(event, index)}
            />
            {index < RESIDENT_PART_COUNT - 1 && <span className={styles.separator}>-</span>}
          </div>
        ))}
      </div>

      {message && (
        <span className={cn(styles.message, helperClassName)}>
          {message}
        </span>
      )}
    </div>
  )
}

상세 설명

주요 props
labelbuttonLabelonButtonClickhelperTexterrorTextinvaliddisabledvaluesdefaultValuesplaceholdersnamesmaxLengthsclassNamefieldClassNameinputClassNamelabelClassNamebuttonClassNamehelperClassNameonChange
  • RESIDENT_PART_COUNT가 2이고 기본 maxLengths는 [6, 7]입니다.
  • values/defaultValues/placeholders/names/maxLengths 배열을 각 input에 분배합니다.
  • onChange(event, index) 형태로 변경된 구간을 상위 컴포넌트가 식별할 수 있습니다.
  • inputMode="numeric"으로 모바일 숫자 입력 UX를 개선합니다.

사용 가이드 JSX

pages/InputGuide.jsx
import Input from '@/components/TextField/Input'

export default function InputGuide() {
  return (
    <section className="flex flex-col gap-8 p-6" style={{ backgroundColor: '#f0f0f0' }}>
      <h2 className="text-title-s">Input</h2>

      <div className="grid max-w-5xl grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
        <div className="flex flex-col gap-3">
          <h3 className="text-body-s">default</h3>
          <Input label="제목" placeholder="문구" buttonLabel="버튼명" />
        </div>

        <div className="flex flex-col gap-3">
          <h3 className="text-body-s">value</h3>
          <Input label="제목" defaultValue="문구" buttonLabel="버튼명" />
        </div>

        <div className="flex flex-col gap-3">
          <h3 className="text-body-s">disabled</h3>
          <Input label="제목" placeholder="문구" buttonLabel="버튼명" disabled />
        </div>

        <div className="flex flex-col gap-3">
          <h3 className="text-body-s">error</h3>
          <Input label="제목" defaultValue="문구" buttonLabel="버튼명" errorText="문구" />
        </div>

        <div className="flex flex-col gap-3">
          <h3 className="text-body-s">helper text</h3>
          <Input label="제목" placeholder="문구" buttonLabel="버튼명" helperText="문구" />
        </div>

        <div className="flex flex-col gap-3">
          <h3 className="text-body-s">without button</h3>
          <Input label="제목" placeholder="문구" />
        </div>
      </div>
    </section>
  )
}
pages/InputareaGuide.jsx
import InputArea from '@/components/TextField/InputArea'

export default function InputareaGuide() {
  return (
    <section className="flex flex-col gap-8 p-6" style={{ backgroundColor: '#f0f0f0' }}>
      <h2 className="text-title-s">InputArea</h2>

      <div className="grid max-w-7xl grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
        <div className="flex flex-col gap-3">
          <h3 className="text-body-s">default</h3>
          <InputArea label="제목" placeholder="문구" buttonLabel="버튼명" />
        </div>

        <div className="flex flex-col gap-3">
          <h3 className="text-body-s">value</h3>
          <InputArea label="제목" defaultValue="문구" buttonLabel="버튼명" />
        </div>

        <div className="flex flex-col gap-3">
          <h3 className="text-body-s">disabled</h3>
          <InputArea label="제목" placeholder="문구" buttonLabel="버튼명" disabled />
        </div>

        <div className="flex flex-col gap-3">
          <h3 className="text-body-s">error</h3>
          <InputArea label="제목" defaultValue="문구" buttonLabel="버튼명" errorText="문구" />
        </div>

        <div className="flex flex-col gap-3">
          <h3 className="text-body-s">helper text</h3>
          <InputArea label="제목" placeholder="문구" buttonLabel="버튼명" helperText="문구" />
        </div>

        <div className="flex flex-col gap-3">
          <h3 className="text-body-s">without button</h3>
          <InputArea label="제목" placeholder="문구" />
        </div>
      </div>
    </section>
  )
}
pages/InputPriceGuide.jsx
import InputPrice from '@/components/TextField/InputPrice'

export default function InputPriceGuide() {
  return (
    <section className="flex flex-col gap-8 p-6" style={{ backgroundColor: '#f0f0f0' }}>
      <h2 className="text-title-s">InputPrice</h2>

      <div className="grid max-w-5xl grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
        <div className="flex flex-col gap-3">
          <h3 className="text-body-s">default</h3>
          <InputPrice label="제목" placeholder="문구" buttonLabel="버튼명" />
        </div>

        <div className="flex flex-col gap-3">
          <h3 className="text-body-s">value</h3>
          <InputPrice label="제목" defaultValue="문구" buttonLabel="버튼명" />
        </div>

        <div className="flex flex-col gap-3">
          <h3 className="text-body-s">disabled</h3>
          <InputPrice label="제목" placeholder="문구" buttonLabel="버튼명" disabled />
        </div>

        <div className="flex flex-col gap-3">
          <h3 className="text-body-s">error</h3>
          <InputPrice label="제목" defaultValue="문구" buttonLabel="버튼명" errorText="문구" />
        </div>

        <div className="flex flex-col gap-3">
          <h3 className="text-body-s">helper text</h3>
          <InputPrice label="제목" placeholder="문구" buttonLabel="버튼명" helperText="문구" />
        </div>

        <div className="flex flex-col gap-3">
          <h3 className="text-body-s">custom unit</h3>
          <InputPrice label="제목" placeholder="문구" buttonLabel="버튼명" unit="만원" />
        </div>
      </div>
    </section>
  )
}
pages/InputCardGuide.jsx
import InputCard from '@/components/TextField/InputCard'

export default function InputCardGuide() {
  return (
    <section className="flex flex-col gap-8 p-6" style={{ backgroundColor: '#f0f0f0' }}>
      <h2 className="text-title-s">InputCard</h2>

      <div className="grid max-w-7xl grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
        <div className="flex flex-col gap-3">
          <h3 className="text-body-s">default</h3>
          <InputCard label="제목" buttonLabel="버튼명" />
        </div>

        <div className="flex flex-col gap-3">
          <h3 className="text-body-s">value</h3>
          <InputCard label="제목" buttonLabel="버튼명" defaultValues={['문구', '문구', '문구', '문구']} />
        </div>

        <div className="flex flex-col gap-3">
          <h3 className="text-body-s">disabled</h3>
          <InputCard label="제목" buttonLabel="버튼명" disabled />
        </div>

        <div className="flex flex-col gap-3">
          <h3 className="text-body-s">error</h3>
          <InputCard
            label="제목"
            buttonLabel="버튼명"
            defaultValues={['문구', '문구', '문구', '문구']}
            errorText="문구"
          />
        </div>

        <div className="flex flex-col gap-3">
          <h3 className="text-body-s">helper text</h3>
          <InputCard label="제목" buttonLabel="버튼명" helperText="문구" />
        </div>

        <div className="flex flex-col gap-3">
          <h3 className="text-body-s">without button</h3>
          <InputCard label="제목" />
        </div>
      </div>
    </section>
  )
}
pages/InputResidentGuide.jsx
import InputResident from '@/components/TextField/InputResident'

export default function InputResidentGuide() {
  return (
    <section className="flex flex-col gap-8 p-6" style={{ backgroundColor: '#f0f0f0' }}>
      <h2 className="text-title-s">InputResident</h2>

      <div className="grid max-w-7xl grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
        <div className="flex flex-col gap-3">
          <h3 className="text-body-s">default</h3>
          <InputResident label="제목" buttonLabel="버튼명" />
        </div>

        <div className="flex flex-col gap-3">
          <h3 className="text-body-s">value</h3>
          <InputResident label="제목" buttonLabel="버튼명" defaultValues={['문구', '문구']} />
        </div>

        <div className="flex flex-col gap-3">
          <h3 className="text-body-s">disabled</h3>
          <InputResident label="제목" buttonLabel="버튼명" disabled />
        </div>

        <div className="flex flex-col gap-3">
          <h3 className="text-body-s">error</h3>
          <InputResident label="제목" buttonLabel="버튼명" defaultValues={['문구', '문구']} errorText="문구" />
        </div>

        <div className="flex flex-col gap-3">
          <h3 className="text-body-s">helper text</h3>
          <InputResident label="제목" buttonLabel="버튼명" helperText="문구" />
        </div>

        <div className="flex flex-col gap-3">
          <h3 className="text-body-s">without button</h3>
          <InputResident label="제목" />
        </div>
      </div>
    </section>
  )
}

TimePicker

1개 JSX 컴포넌트가 있습니다.

TimePicker

TimePicker

react-mobile-picker 기반의 시/분 휠 피커입니다.

Top

컴포넌트 코드

components/TimePicker/TimePicker.jsx

import { useState } from 'react'
import Picker from 'react-mobile-picker'
import { cn } from '@/utils/cn'
import styles from './TimePicker.module.css'

function range(length) {
  return Array.from({ length }, (_, index) => String(index).padStart(2, '0'))
}

function getNextValue(options, currentValue, amount) {
  const currentIndex = Math.max(0, options.indexOf(currentValue))
  const nextIndex = (currentIndex + amount + options.length) % options.length

  return options[nextIndex]
}

const DEFAULT_HOURS = range(24)
const DEFAULT_MINUTES = range(60)

export default function TimePicker({
  value,
  defaultValue = { hour: '00', minute: '00' },
  onChange,
  hours = DEFAULT_HOURS,
  minutes = DEFAULT_MINUTES,
  buttonLabel = '\ubc84\ud2bc\uba85',
  onButtonClick,
  className,
  pickerClassName,
  columnClassName,
  itemClassName,
  selectedItemClassName,
  height = 168,
  itemHeight = 56,
  wheelMode = 'natural',
}) {
  const [internalValue, setInternalValue] = useState(defaultValue)
  const pickerValue = value ?? internalValue

  function handleChange(nextValue, key) {
    setInternalValue(nextValue)
    onChange?.(nextValue, key)
  }

  function handleStep(key, amount) {
    const options = key === 'hour' ? hours : minutes
    const nextValue = {
      ...pickerValue,
      [key]: getNextValue(options, pickerValue[key], amount),
    }

    handleChange(nextValue, key)
  }

  return (
    <div className={cn(styles.wrap, className)}>
      <div className={styles.pickerArea}>
        <div className={styles.controls}>
          <button className={styles.controlButton} type="button" aria-label="Increase hour" onClick={() => handleStep('hour', 1)}>
            <span className={styles.upIcon} />
          </button>
          <span />
          <button className={styles.controlButton} type="button" aria-label="Increase minute" onClick={() => handleStep('minute', 1)}>
            <span className={styles.upIcon} />
          </button>
        </div>

        <Picker
          value={pickerValue}
          height={height}
          itemHeight={itemHeight}
          wheelMode={wheelMode}
          className={cn(styles.picker, pickerClassName)}
          onChange={handleChange}
        >
          <Picker.Column name="hour" className={cn(styles.column, columnClassName)}>
            {hours.map((hour) => (
              <Picker.Item key={hour} value={hour}>
                {({ selected }) => (
                  <span className={cn(styles.item, selected && styles.selectedItem, itemClassName, selected && selectedItemClassName)}>
                    {hour}
                  </span>
                )}
              </Picker.Item>
            ))}
          </Picker.Column>

          <div className={styles.separator} aria-hidden="true">
            :
          </div>

          <Picker.Column name="minute" className={cn(styles.column, columnClassName)}>
            {minutes.map((minute) => (
              <Picker.Item key={minute} value={minute}>
                {({ selected }) => (
                  <span className={cn(styles.item, selected && styles.selectedItem, itemClassName, selected && selectedItemClassName)}>
                    {minute}
                  </span>
                )}
              </Picker.Item>
            ))}
          </Picker.Column>
        </Picker>

        <div className={styles.controls}>
          <button className={styles.controlButton} type="button" aria-label="Decrease hour" onClick={() => handleStep('hour', -1)}>
            <span className={styles.downIcon} />
          </button>
          <span />
          <button className={styles.controlButton} type="button" aria-label="Decrease minute" onClick={() => handleStep('minute', -1)}>
            <span className={styles.downIcon} />
          </button>
        </div>
      </div>

      {buttonLabel && (
        <button className={styles.button} type="button" onClick={() => onButtonClick?.(pickerValue)}>
          {buttonLabel}
        </button>
      )}
    </div>
  )
}

상세 설명

주요 props
valuedefaultValueonChangehoursminutesbuttonLabelonButtonClickclassNamepickerClassNamecolumnClassNameitemClassNameselectedItemClassNameheightitemHeightwheelMode
  • hours와 minutes 옵션 배열을 외부에서 바꿀 수 있고 기본값은 00-23, 00-59입니다.
  • value가 있으면 controlled, 없으면 defaultValue 기반 internalValue로 동작합니다.
  • 상하 버튼은 getNextValue로 배열을 순환하며 hour/minute 값을 한 칸씩 이동합니다.
  • Picker.Column 두 개와 가운데 separator를 조합해 시간 선택 UI를 구성합니다.
  • buttonLabel을 제공하면 선택 완료 CTA를 렌더링하고 onButtonClick에 현재 값을 넘깁니다.

사용 가이드 JSX

pages/TimePickerGuide.jsx
import { useState } from 'react'
import TimePicker from '@/components/TimePicker/TimePicker'

export default function TimePickerGuide() {
    const [time, setTime] = useState({ hour: '00', minute: '00' })

    return (
        <section className="flex flex-col gap-8 p-6" style={{ backgroundColor: '#f0f0f0' }}>
            <h2 className="text-title-s">TimePicker</h2>

            <div className="flex flex-wrap items-start gap-6">
                <div className="flex flex-col gap-3">
                    <h3 className="text-body-s">default</h3>
                    <TimePicker />
                </div>

                <div className="flex flex-col gap-3">
                    <h3 className="text-body-s">controlled</h3>
                    <TimePicker value={time} onChange={setTime} onButtonClick={setTime} />
                    <div className="text-caption-l">
                        {time.hour}:{time.minute}
                    </div>
                </div>

                <div className="flex flex-col gap-3">
                    <h3 className="text-body-s">step 5 minutes</h3>
                    <TimePicker
                        defaultValue={{ hour: '09', minute: '30' }}
                        minutes={['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55']}
                    />
                </div>
            </div>
        </section>
    )
}

Typography

1개 JSX 컴포넌트가 있습니다.

Typography

Typography

tagName으로 실제 HTML 태그를 바꾸면서 타이포그래피 클래스를 적용하는 컴포넌트입니다.

Top

컴포넌트 코드

components/Typography/Typography.jsx

import { cn } from "@/utils/cn"
import styles from "./Typography.module.css"

/*
  사용 예제

  <Typography tagName="h1" className="text-display">
    메인 타이틀
  </Typography>

  <Typography tagName="p" className="text-title-xl">
    본문 텍스트
  </Typography>

  <Typography tagName="span" className="text-title-xl" id="user-name" aria-label="사용자 이름">
    홍길동
  </Typography>
*/

export default function Typography({
  tagName: Tag = "p",
  className = "",
  children,
  ...rest
}) {
  const moduleClassName = className
    .split(/\s+/)
    .filter(Boolean)
    .map((className) => styles[className])

  return (
    <Tag className={cn(moduleClassName, className)} {...rest}>
      {children}
    </Tag>
  )
}

상세 설명

주요 props
tagNameclassNamechildren
  • tagName prop을 Tag 변수로 받아 h1, p, span 등 원하는 태그로 렌더링합니다.
  • className 문자열을 공백으로 나눠 CSS module의 동일 key를 찾아 적용하고, 원본 className도 함께 유지합니다.
  • 디자인 토큰 클래스와 Tailwind/전역 클래스를 함께 넘기는 형태를 허용합니다.

사용 가이드 JSX

pages/TypographyGuide.jsx
import { cn } from "@/utils/cn"
import Typography from "@/components/Typography/Typography"

export default function TypographyGuide() {
    return (
        <>
            <Typography tagName="h1" className="text-display">
                폰트 사이즈 60px 굵기 700 행간 72px 입니다.
            </Typography>

            <Typography tagName="h2" className="text-title-xl">
                타이틀 폰트 사이즈 40px 굵기 700 행간 48px 입니다.
            </Typography>

            <Typography tagName="h3" className="text-title-l">
                타이틀 폰트 사이즈 32px 굵기 700 행간 38px 입니다.
            </Typography>

            <Typography tagName="h4" className="text-title-m">
                타이틀 폰트 사이즈 26px 굵기 700 행간 31px 입니다.
            </Typography>

            <Typography tagName="h5" className="text-title-s">
                타이틀 폰트 사이즈 22px 굵기 700 행간 26px 입니다.
            </Typography>

            <Typography tagName="h6" className="text-title-s2">
                타이틀 폰트 사이즈 22px 굵기 600 행간 28px 입니다.
            </Typography>

            <Typography className="text-body-xl">
                본문 폰트 사이즈 20px 굵기 700 행간 24px 입니다.
            </Typography>

            <Typography className="text-body-l">
                본문 폰트 사이즈 18px 굵기 700 행간 21px 입니다.
            </Typography>
            
            <Typography className="text-body-m">
                본문 폰트 사이즈 16px 굵기 700 행간 19px 입니다.
            </Typography>

            <Typography className="text-body-s">
                본문 폰트 사이즈 15px 굵기 700 행간 18px 입니다.
            </Typography>

            <Typography tagName="span" className={cn('block', 'text-caption-l')}>
                캡션 폰트 사이즈 15px 굵기 700 행간 17px 입니다.
            </Typography>

            <Typography tagName="span" className={cn('block','text-caption-m')}>
                캡션 폰트 사이즈 13px 굵기 400 행간 16px 입니다.
            </Typography>

            <Typography tagName="span" className={cn('block','text-caption-s')}>
                캡션 폰트 사이즈 13px 굵기 400 행간 16px 입니다.
            </Typography>

            <Typography tagName="em" className={cn('block','text-label-l')}>
                레이블 폰트 사이즈 10px 굵기 600 행간 12px 입니다.
            </Typography>

            <Typography tagName="em" className={cn('block','text-label-m')}>
                레이블 폰트 사이즈 9px 굵기 600 행간 11px 입니다.
            </Typography>

            <Typography tagName="em" className={cn('block','text-label-s')}>
                레이블 폰트 사이즈 7px 굵기 700 행간 8px 입니다.
            </Typography>

        </>
    )
}