Drawer

Interactive UI Explorer

아래에서 각 컴포넌트의 다양한 Variants States를 문서화된 형태로 테스트해볼 수 있습니다.

Live Preview

공유하기

💬
📋
🔗
📷
📧
🖨️

Drawer

화면 가장자리에서 슬라이드되는 패널(시트)입니다. 4방향(bottom/right/left/top), 백드롭·ESC 닫기·스크롤 락. bottom은 핸들을 끌어 닫을 수 있습니다.

방향 선택 + 열기

side로 등장 방향을 정합니다. open/onClose로 제어하는 컴포넌트입니다.

Source Code
const [open, setOpen] = useState(false);

<Drawer open={open} onClose={() => setOpen(false)} side="bottom" title="공유하기">
  …내용…
</Drawer>
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,
  );
}