컴포넌트의 목적부터 props, 상태관리, 조건부 렌더링, CSS Module, ...props까지 한 페이지에서 정리합니다.
배열로 전달받은 탭 목록을 반복 생성하고, 현재 선택된 탭을 관리하는 컴포넌트입니다. button 탭과 link 탭을 모두 지원합니다.
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>
)
}
function getItemValue(item, index) {
return item.value ?? item.href ?? String(index)
}
각 탭을 구분하기 위한 고유 값을 결정합니다. 앞의 값이 null 또는 undefined라면 다음 값을 사용합니다.
{ label: '상품', value: 'product' }
// → "product"
{ label: '회사소개', href: '/company' }
// → "/company"
{ label: '기타' } // index가 2
// → "2"
??는 왼쪽 값이 null 또는 undefined일 때만 오른쪽 값을 사용합니다.export default function Tab({
items = [],
type = 'button',
value,
defaultValue,
onChange,
className,
itemClassName,
activeItemClassName,
...props
})
| props | 역할 |
|---|---|
items | 탭 목록 배열 |
type | button 또는 link |
value | 부모가 직접 관리하는 현재 선택값 |
defaultValue | 내부 상태의 초기 선택값 |
onChange | 탭 클릭 후 부모에게 선택 결과 전달 |
className | 전체 Tab 영역 추가 클래스 |
itemClassName | 모든 탭 항목 추가 클래스 |
activeItemClassName | 활성화된 탭에만 추가 클래스 |
...props | 나머지 속성을 최상위 div에 전달 |
const firstValue =
items[0] ? getItemValue(items[0], 0) : undefined
첫 번째 데이터가 있으면 첫 번째 탭의 값을 가져옵니다.
const items = [
{ label: '홈', value: 'home' },
{ label: '상품', value: 'product' }
]
// firstValue → "home"
const [internalValue, setInternalValue] =
useState(defaultValue ?? firstValue)
defaultValue가 있으면 그것을 초기값으로 사용하고, 없으면 첫 번째 탭의 값을 초기값으로 사용합니다.
<Tab
items={items}
defaultValue="product"
/>
// 초기 선택 → product
<Tab items={items} />
// 초기 선택 → 첫 번째 탭 home
const selectedValue = value ?? internalValue
외부에서 value가 들어오면 외부 값을 사용하고, 없으면 내부 state를 사용합니다.
Tab 자신이 선택 상태를 관리합니다.
<Tab
items={items}
defaultValue="home"
/>
부모 컴포넌트가 상태를 관리합니다.
const [tab, setTab] = useState('home')
<Tab
items={items}
value={tab}
onChange={(value) => setTab(value)}
/>
value ?? internalValue 구조 때문에 이 Tab은 controlled와 uncontrolled 방식을 모두 지원합니다.const isLinkType = type === 'link'
type="link"
// isLinkType → true
type="button"
// isLinkType → false
function handleChange(nextValue, item, event) {
if (!isLinkType) {
setInternalValue(nextValue)
}
onChange?.(nextValue, item, event)
}
button 탭이면 내부 상태를 변경하고, 부모가 onChange를 전달했다면 부모 함수도 호출합니다.
onChange?.(nextValue, item, event)
위 코드는 쉽게 쓰면 다음과 거의 같습니다.
if (onChange) {
onChange(nextValue, item, event)
}
?.()는 함수가 존재할 때만 실행하는 optional chaining 문법입니다.
<Tab
items={items}
onChange={(value, item, event) => {
console.log(value)
console.log(item)
}}
/>
상품 탭 클릭 시:
value
// "product"
item
// { label: "상품", value: "product" }
if (!items.length) return null
items = []이면 items.length가 0이므로 화면에 아무것도 렌더링하지 않습니다.
{items.map((item, index) => {
...
})}
items가 3개라면 button 또는 a 태그도 3개 만들어집니다.
const isActive =
itemValue === selectedValue || item.active
현재 선택값과 같거나, item 자체에 active: true가 있으면 활성 상태가 됩니다.
const classNameValue = cn(
styles.item,
isActive && styles.active,
itemClassName,
isActive && activeItemClassName
)
기본 클래스는 항상 적용하고, 활성화된 경우에만 active 관련 클래스를 추가합니다.
<Tab
type="link"
items={[
{ label: '회사소개', href: '/company' },
{ label: '고객센터', href: '/support' }
]}
/>
이 경우 내부에서는 <a> 태그를 생성합니다.
<Tab
items={[
{ label: '홈', value: 'home' },
{ label: '상품', value: 'product' },
{ label: '회원', value: 'member', disabled: true }
]}
/>
기본 type="button"이므로 <button> 태그들이 만들어집니다.
텍스트용 HTML 태그를 상황에 따라 p, h1, span 등으로 바꾸고, CSS Module 클래스를 공통 처리하기 위한 컴포넌트입니다.
import { cn } from "@/utils/cn"
import styles from "./Typography.module.css"
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>
)
}
tagName: Tag = "p"
구조분해 할당을 하면서 tagName을 내부에서는 Tag라는 변수명으로 사용합니다. 값이 없으면 기본값은 "p"입니다.
<Typography tagName="h1">
제목
</Typography>
// 실제 결과
<h1>제목</h1>
<Typography tagName="span">
홍길동
</Typography>
// 실제 결과
<span>홍길동</span>
<Typography>
기본 본문
</Typography>
// tagName을 안 줬으므로
<p>기본 본문</p>
Tag에 태그 이름을 넣어 두고 <Tag>처럼 동적으로 렌더링할 수 있습니다.className.split(/\s+/)
공백을 기준으로 하나의 className 문자열을 배열로 나눕니다.
"text-title-xl bold"
// ↓
["text-title-xl", "bold"]
.filter(Boolean)
배열 안의 빈 문자열 같은 falsy 값을 제거합니다.
"text-title-xl bold"
.split(/\s+/)
.filter(Boolean)
// ["text-title-xl", "bold"]
.map((className) => styles[className])
문자열로 전달된 클래스 이름을 CSS Module 객체에서 실제 생성된 클래스 이름으로 변환합니다.
// Typography.module.css
.text-title-xl {
font-size: 24px;
}
.bold {
font-weight: bold;
}
styles["text-title-xl"]
styles["bold"]
// 빌드 후 대략
[
"Typography_text-title-xl_a81sd",
"Typography_bold_k29fd"
]
<Tag
className={cn(moduleClassName, className)}
>
cn()으로 CSS Module에서 변환된 클래스와 원래 전달받은 클래스 문자열을 합칩니다.
<Typography
tagName="h1"
className="text-display"
>
제목
</Typography>
실제 결과는 대략 다음과 비슷합니다.
<h1 class="Typography_text-display_xxx text-display">
제목
</h1>
export default function Typography({
tagName: Tag = "p",
className = "",
children,
...rest
})
직접 꺼낸 tagName, className, children을 제외한 나머지 props가 모두 rest에 들어갑니다.
<Typography
tagName="span"
className="text-title-xl"
id="user-name"
aria-label="사용자 이름"
title="회원 이름"
>
홍길동
</Typography>
여기서 rest에는 다음이 들어갑니다.
{
id: "user-name",
"aria-label": "사용자 이름",
title: "회원 이름"
}
그리고:
<Tag {...rest}>
를 통해 실제 HTML 태그에 전달됩니다.
실제 기능은 input type="checkbox"가 담당하고, track과 thumb span은 토글 스위치처럼 보이게 만드는 디자인 요소입니다.
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>
)
}
const switchLabel = label ?? children
label이 있으면 label을 사용하고, 없으면 children을 사용합니다.
<Switch label="알림 받기" />
<Switch>
알림 받기
</Switch>
둘 다 결과적으로 switchLabel은 "알림 받기"가 됩니다.
<label className={cn(styles.switch, className)}>
...
</label>
input과 표시 텍스트가 하나의 label 영역 안에 있기 때문에 텍스트나 스위치 영역을 클릭해도 input이 동작할 수 있습니다.
<input
{...props}
className={styles.input}
type="checkbox"
role="switch"
/>
화면에서는 스위치처럼 보이지만 실제 상태는 checkbox가 관리합니다.
track, thumb가 ON/OFF 기능을 만드는 것이 아니라 <input type="checkbox">가 실제 기능을 담당합니다.role="switch"
접근성 도구에 이 체크박스가 일반적인 체크 항목보다는 ON/OFF 스위치 역할을 한다는 의미를 알려줍니다.
<input {...props} ... />
Switch 사용 시 별도로 선언하지 않은 input 속성을 그대로 사용할 수 있습니다.
const [enabled, setEnabled] = useState(false)
<Switch
label="알림 받기"
checked={enabled}
onChange={(event) => {
setEnabled(event.target.checked)
}}
disabled={false}
name="notification"
/>
여기서 다음 값들이 모두 props로 들어갑니다.
checked
onChange
disabled
name
그리고 내부 input에 그대로 전달됩니다.
<input
checked={enabled}
onChange={...}
disabled={false}
name="notification"
type="checkbox"
/>
checked, onChange, disabled, name, id 등을 컴포넌트 props에서 하나씩 모두 선언하지 않아도 됩니다.<span className={styles.track}>
<span className={styles.thumb} />
</span>
예를 들어 CSS는 다음과 같은 구조로 만들 수 있습니다.
.track {
width: 40px;
height: 22px;
border-radius: 20px;
}
.thumb {
width: 18px;
height: 18px;
border-radius: 50%;
}
checkbox가 체크됐을 때 CSS 선택자를 이용해 배경이나 thumb 위치를 바꿉니다.
.input:checked + .track {
/* ON 상태의 track */
}
.input:checked + .track .thumb {
transform: translateX(18px);
}
<span
className={styles.track}
aria-hidden="true"
>
track/thumb은 디자인을 위한 요소이므로 스크린 리더가 별도의 의미 있는 콘텐츠로 읽지 않도록 합니다.
{switchLabel && (
<span className={cn(styles.text, textClassName)}>
{switchLabel}
</span>
)}
switchLabel이 있을 때만 span을 생성합니다.
<Switch />
// → 텍스트 없음
<Switch label="자동 로그인" />
// → "자동 로그인" 텍스트 생성
items = []
type = 'button'
className = ""
tagName: Tag = "p"
props가 없을 때 사용할 기본값을 구조분해 단계에서 지정합니다.
label ?? children
value ?? internalValue
defaultValue ?? firstValue
왼쪽이 null/undefined일 때 오른쪽 값을 사용합니다.
...props
...rest
컴포넌트가 모든 HTML 속성을 일일이 선언하지 않아도 되게 해줍니다.
cn(
styles.item,
isActive && styles.active,
itemClassName
)
기본 클래스 + 조건부 클래스 + 외부 클래스를 합칩니다.
if (!items.length) return null
switchLabel && (
<span>...</span>
)
조건에 따라 요소 자체를 만들거나 만들지 않습니다.
role="tab"
aria-selected={isActive}
role="switch"
aria-hidden="true"
화면 모양뿐 아니라 요소의 의미도 전달합니다.
| 컴포넌트 | 핵심 역할 | 가장 중요한 코드 |
|---|---|---|
| Tab | 여러 탭 중 현재 선택값 관리 | value ?? internalValueitems.map()handleChange() |
| Typography | 텍스트 태그와 CSS 스타일 공통화 | tagName: Tag = "p"styles[className]...rest |
| Switch | checkbox를 ON/OFF 스위치 UI로 표현 | label ?? children<input {...props}>.input:checked |
특히 Tab에서는 controlled/uncontrolled 구조, Typography에서는 동적 Tag, Switch에서는 실제 input과 디자인용 span의 역할 구분을 이해하면 전체 코드가 훨씬 쉽게 보입니다.