Drawer
Interactive UI Explorer
아래에서 각 컴포넌트의 다양한 Variants와 States를 문서화된 형태로 테스트해볼 수 있습니다.
Live Preview
공유하기
💬
📋
🔗
📷
📧
⭐
🖨️
⋯
Drawer
화면 가장자리에서 슬라이드되는 패널(시트)입니다. 4방향(bottom/right/left/top), 백드롭·ESC 닫기·스크롤 락. bottom은 핸들을 끌어 닫을 수 있습니다.
방향 선택 + 열기
side로 등장 방향을 정합니다. open/onClose로 제어하는 컴포넌트입니다.
Source Code
Live Preview
Implementation
제작 코드
이 컴포넌트가 실제로 어떻게 구현되어 있는지 — 본체 .tsx 파일을 그대로 보여줍니다. variant 매핑·접근성 처리·forwardRef 패턴 등 디테일을 그대로 확인할 수 있어요.
Drawertypescript
'use client';
/**
* Drawer 컴포넌트
*
* 화면 가장자리에서 슬라이드되는 패널(시트). 4방향(bottom/right/left/top).
* - 백드롭 클릭·ESC 닫기, 열려 있는 동안 body 스크롤 락.
* - side="bottom"이면 핸들을 아래로 끌어 닫기(drag-to-dismiss).
* - 제어 컴포넌트: open / onClose.
*/
import { useEffect, useRef, useState, type PointerEvent, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { cn } from '@/lib/cn';
export type DrawerSide = 'bottom' | 'right' | 'left' | 'top';
export interface DrawerProps {
open: boolean;
onClose: () => void;
side?: DrawerSide;
title?: ReactNode;
children: ReactNode;
showClose?: boolean;
/** right/left: 너비, top/bottom: 최대 높이 (CSS 값) */
size?: string;
className?: string;
}
const sideBase: Record<DrawerSide, string> = {
bottom: 'inset-x-0 bottom-0 mx-auto max-w-md rounded-t-3xl',
top: 'inset-x-0 top-0 mx-auto max-w-md rounded-b-3xl',
right: 'inset-y-0 right-0 h-full w-[var(--drawer-size)] max-w-[92vw] rounded-l-3xl',
left: 'inset-y-0 left-0 h-full w-[var(--drawer-size)] max-w-[92vw] rounded-r-3xl',
};
const hiddenTransform: Record<DrawerSide, string> = {
bottom: 'translateY(100%)',
top: 'translateY(-100%)',
right: 'translateX(100%)',
left: 'translateX(-100%)',
};
export function Drawer({
open,
onClose,
side = 'bottom',
title,
children,
showClose = true,
size,
className,
}: DrawerProps) {
const [dragY, setDragY] = useState<number | null>(null);
const startY = useRef(0);
const panelRef = useRef<HTMLDivElement | null>(null);
// body 스크롤 락
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = prev;
};
}, [open]);
// ESC 닫기
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [open, onClose]);
const draggable = side === 'bottom';
const onDown = (e: PointerEvent<HTMLDivElement>) => {
if (!draggable) return;
startY.current = e.clientY;
setDragY(0);
e.currentTarget.setPointerCapture(e.pointerId);
};
const onMove = (e: PointerEvent<HTMLDivElement>) => {
if (dragY === null) return;
setDragY(Math.max(-40, e.clientY - startY.current));
};
const onUp = () => {
if (dragY === null) return;
const h = panelRef.current?.offsetHeight ?? 400;
if (dragY > h * 0.35) onClose();
setDragY(null);
};
if (typeof window === 'undefined') return null;
const defaultSize = side === 'right' || side === 'left' ? '20rem' : undefined;
const dragging = dragY !== null;
const transform = open
? side === 'bottom' && dragging
? `translateY(${dragY}px)`
: 'translate(0, 0)'
: hiddenTransform[side];
return createPortal(
<div className="pointer-events-none fixed inset-0 z-110" aria-hidden={!open}>
{/* 백드롭 */}
<div
onClick={onClose}
aria-hidden
className={cn(
'absolute inset-0 bg-slate-900/40 backdrop-blur-[2px] transition-opacity duration-300',
open ? 'pointer-events-auto opacity-100' : 'opacity-0',
)}
/>
{/* 패널 */}
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-label={typeof title === 'string' ? title : '드로어'}
style={{
transform,
transition: dragging ? 'none' : undefined,
['--drawer-size' as string]: size ?? defaultSize,
}}
className={cn(
'pointer-events-auto absolute flex max-h-[90vh] flex-col bg-white shadow-2xl',
'transition-transform duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none',
sideBase[side],
!open && 'pointer-events-none',
className,
)}
>
{draggable && (
<div
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
className="flex shrink-0 cursor-grab touch-none flex-col items-center gap-2 pt-3 active:cursor-grabbing"
>
<span className="h-1.5 w-10 rounded-full bg-slate-300" />
</div>
)}
{(title || showClose) && (
<div className={cn('flex shrink-0 items-center justify-between px-5', draggable ? 'pb-3 pt-2' : 'py-4')}>
<h2 className="text-base font-bold text-slate-900">{title}</h2>
{showClose && (
<button
type="button"
onClick={onClose}
aria-label="닫기"
className="grid size-8 place-items-center rounded-full text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
>
<svg className="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M18 6L6 18" />
<path d="M6 6L18 18" />
</svg>
</button>
)}
</div>
)}
<div className="min-h-0 flex-1 overflow-y-auto px-5 pb-6">{children}</div>
</div>
</div>,
document.body,
);
}