1. cn.js
cn은 CSS 클래스 이름을 편하게 합쳐주는 함수입니다. React에서 조건에 따라 클래스명을 넣거나 빼야 할 때
문자열을 직접 이어 붙이지 않아도 됩니다.
한 줄 요약
문자열, 배열, 객체를 받아서 최종 className 문자열로 바꿉니다.
주로 쓰는 곳
<button className={cn(...)}>처럼 조건부 스타일을 적용하는 곳
반환값
공백으로 연결된 클래스 문자열
원본 코드
export function cn(...inputs) {
return inputs
.flatMap((input) => {
if (!input) return []
if (Array.isArray(input)) {
return cn(...input)
}
if (typeof input === "object") {
return Object.entries(input)
.filter(([, value]) => Boolean(value))
.map(([key]) => key)
}
return String(input)
})
.join(" ")
}
코드 흐름
1
...inputs로 여러 값을 받습니다.2
false, null, 빈 문자열 같은 값은 버립니다.3배열이면 다시
cn을 호출해서 안쪽 값도 처리합니다.4객체이면 값이
true인 key만 클래스명으로 사용합니다.사용 예시
import { cn } from "./utils/cn"
function Button({ active, disabled, children }) {
return (
<button
className={cn(
"btn",
active && "btn-active",
disabled && "btn-disabled",
{ "btn-clickable": !disabled }
)}
disabled={disabled}
>
{children}
</button>
)
}
예를 들어 active가 true이고 disabled가 false이면 결과는
"btn btn-active btn-clickable"이 됩니다.
2. focusTrap.js
createFocusTrap은 모달이나 팝업이 열렸을 때 키보드 포커스가 그 안에서만 돌도록 만드는 함수입니다.
접근성에서 중요한 기능입니다.
한 줄 요약
Tab 키를 눌렀을 때 포커스가 모달 밖으로 빠져나가지 않게 막습니다.
주로 쓰는 곳
모달, 드로어, 팝업 메뉴처럼 화면 위에 떠 있는 UI
반환값
이벤트를 정리하고 이전 포커스로 되돌리는 cleanup 함수
핵심 개념
FOCUSABLE_SELECTOR: 버튼, 링크, input처럼 키보드 포커스를 받을 수 있는 요소 목록입니다.getFocusableElements: 컨테이너 안에서 실제로 보이고 포커스 가능한 요소만 골라냅니다.handleKeyDown: 사용자가Tab또는Shift + Tab을 눌렀을 때 포커스 이동을 제어합니다.cleanup: 모달이 닫힐 때 이벤트를 제거하고, 원래 포커스가 있던 버튼으로 되돌립니다.
코드 흐름
1모달이 열릴 때 현재 포커스 위치를 저장합니다.
2첫 번째 포커스 가능 요소로 포커스를 보냅니다.
3
keydown 이벤트로 Tab 이동을 감시합니다.4모달이 닫히면 이벤트를 제거하고 이전 포커스를 복원합니다.
사용 예시
import { useEffect, useRef } from "react"
import { createFocusTrap } from "./utils/focusTrap"
function Modal({ open, onClose }) {
const modalRef = useRef(null)
useEffect(() => {
if (!open || !modalRef.current) return
const cleanupFocusTrap = createFocusTrap(modalRef.current)
return () => {
cleanupFocusTrap()
}
}, [open])
if (!open) return null
return (
<div className="modal-backdrop">
<div ref={modalRef} className="modal" role="dialog" aria-modal="true">
<h2>알림</h2>
<p>Tab 키를 눌러도 포커스는 이 모달 안에서만 이동합니다.</p>
<button onClick={onClose}>닫기</button>
</div>
</div>
)
}
입문자가 기억할 점:
useEffect에서 만든 이벤트나 기능은 컴포넌트가 사라질 때 정리해야 합니다.
그래서 이 함수는 cleanup 함수를 반환합니다.
3. pageScrollLock.js
lockPageScroll은 모달이나 메뉴가 열렸을 때 배경 페이지가 스크롤되지 않도록 막는 함수입니다.
닫을 때는 원래 스크롤 위치와 body 스타일을 되돌립니다.
한 줄 요약
화면 위 UI가 열려 있는 동안 body 스크롤을 잠급니다.
주로 쓰는 곳
모달, 모바일 전체 메뉴, 바텀시트
반환값
스크롤 잠금을 해제하는 cleanup 함수
핵심 변수
lockCount: 여러 모달이 동시에 열려도 마지막 하나가 닫힐 때까지 잠금을 유지하기 위한 숫자입니다.scrollY: 잠그기 전 사용자가 보고 있던 세로 스크롤 위치입니다.bodyHadOpenedClass: 원래 body에opened클래스가 있었는지 기억합니다.previousBodyStyles: 잠그기 전 body의 inline style 값을 저장합니다.
코드 흐름
1처음 잠글 때 현재 스크롤 위치와 body 스타일을 저장합니다.
2body에
opened 클래스를 추가합니다.3
position: fixed, overflow: hidden으로 배경 스크롤을 막습니다.4cleanup 때 스타일을 복원하고 원래 스크롤 위치로 돌아갑니다.
사용 예시
import { useEffect } from "react"
import { lockPageScroll } from "./utils/pageScrollLock"
function MobileMenu({ open, onClose }) {
useEffect(() => {
if (!open) return
const unlockPageScroll = lockPageScroll()
return () => {
unlockPageScroll?.()
}
}, [open])
if (!open) return null
return (
<aside className="mobile-menu">
<button onClick={onClose}>닫기</button>
<nav>
<a href="/products">상품</a>
<a href="/support">고객센터</a>
</nav>
</aside>
)
}
왜
lockCount가 필요할까요?
모달 안에서 또 다른 팝업이 열릴 수 있습니다. 첫 번째 팝업이 닫혔다고 바로 스크롤을 풀면 두 번째 팝업 뒤의 페이지가
움직일 수 있기 때문에, 열린 잠금 개수를 세고 모두 닫혔을 때만 해제합니다.
같이 쓰는 예시: 접근성 있는 모달
실제 모달에서는 createFocusTrap과 lockPageScroll을 함께 쓰는 경우가 많습니다.
포커스는 모달 안에 가두고, 배경 페이지는 스크롤되지 않게 합니다.
import { useEffect, useRef } from "react"
import { cn } from "./utils/cn"
import { createFocusTrap } from "./utils/focusTrap"
import { lockPageScroll } from "./utils/pageScrollLock"
function AccessibleModal({ open, size = "md", onClose, children }) {
const modalRef = useRef(null)
useEffect(() => {
if (!open || !modalRef.current) return
const cleanupFocusTrap = createFocusTrap(modalRef.current)
const unlockPageScroll = lockPageScroll()
return () => {
cleanupFocusTrap()
unlockPageScroll?.()
}
}, [open])
if (!open) return null
return (
<div className="modal-backdrop">
<section
ref={modalRef}
className={cn("modal", {
"modal-sm": size === "sm",
"modal-md": size === "md",
"modal-lg": size === "lg",
})}
role="dialog"
aria-modal="true"
>
{children}
<button onClick={onClose}>닫기</button>
</section>
</div>
)
}