https://ui.shadcn.com/docs/components/drawer
설치
pnpm dlx shadcn@latest add drawer
Drawer 컴포넌트를 다음과 같이 변경합니다.
1)모바일 메뉴 좌우 50% 가리기 소스 : drawer.tsx
src/app/components/ui/drawer-50.tsx
"use client";
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cva } from "class-variance-authority";
import { cn } from "@/lib/utils";
const DrawerContext = React.createContext<{
direction?: "right" | "top" | "bottom" | "left";
}>({
direction: "right",
});
const Drawer = ({
shouldScaleBackground = true,
direction = "right",
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerContext.Provider value={{ direction }}>
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
direction={direction}
{...props}
/>
</DrawerContext.Provider>
);
Drawer.displayName = "Drawer";
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerPortal = DrawerPrimitive.Portal;
const DrawerClose = DrawerPrimitive.Close;
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
));
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const drawerContentVariants = cva(
"fixed z-50 flex h-auto flex-col border bg-background",
{
variants: {
direction: {
right: "ml-24 right-0 rounded-l-[10px] inset-y-0",
top: "mb-24 top-0 rounded-b-[10px] inset-x-0",
bottom: "mt-24 rounded-t-[10px] bottom-0 inset-x-0",
left: "mr-24 left-0 rounded-r-[10px] inset-y-0",
},
},
defaultVariants: {
direction: "right",
},
}
);
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => {
const { direction } = React.useContext(DrawerContext);
return (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(drawerContentVariants({ direction, className }))}
{...props}
>
{/* <div className='mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted' /> */}
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
);
});
DrawerContent.displayName = "DrawerContent";
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
);
DrawerHeader.displayName = "DrawerHeader";
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
DrawerFooter.displayName = "DrawerFooter";
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};
2)모바일화면에서 좌추 100% 풀 가리기 소스 : drawer.tsx
src/app/components/ui/drawer-100.tsx
"use client";
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils";
export const DrawerContext = React.createContext<{
direction?: "top" | "bottom" | "left" | "right" ;
onClose?: () => void;
}>({});
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerContext.Provider
value={{ direction: props.direction, onClose: props.onClose }}
>
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
</DrawerContext.Provider>
);
Drawer.displayName = "Drawer";
const DrawerTrigger = DrawerPrimitive.Trigger;
const DrawerPortal = DrawerPrimitive.Portal;
const DrawerClose = DrawerPrimitive.Close;
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
));
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
>(({ className, children, ...props }, ref) => {
const { direction } = React.useContext(DrawerContext);
return (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed z-50 flex h-auto flex-col rounded-t-[10px] border bg-background",
(!direction || direction === "bottom") && "inset-x-0 bottom-0 mt-24 ",
direction === "right" && "right-0 w-screen max-w-md top-0 h-full",
direction === "top" && "inset-x-0 top-0 mb-24 rounded-b-[10px]",
direction === "left" && "left-0 w-screen max-w-md top-0 h-full",
className
)}
{...props}
>
{(!direction || direction === "bottom") && (
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
)}
{direction === "top" && (
<div className="mx-auto mb-4 h-2 w-[100px] rounded-full bg-muted" />
)}
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
);
});
DrawerContent.displayName = "DrawerContent";
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
);
DrawerHeader.displayName = "DrawerHeader";
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
DrawerFooter.displayName = "DrawerFooter";
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
));
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};
기본적인 사용 예제
"use client";
import React from "react";
import {
Drawer,
DrawerTrigger,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
DrawerClose
} from "@/components/Drawer";
import { Button } from "@/components/ui/button";
export default function Example() {
return (
<Drawer>
<DrawerTrigger asChild>
<Button variant="outline">Open Drawer</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Drawer 제목</DrawerTitle>
<DrawerDescription>이것은 Drawer 설명입니다.</DrawerDescription>
</DrawerHeader>
<div className="p-4">
<p>여기에 원하는 내용을 넣을 수 있습니다.</p>
</div>
<DrawerFooter>
<DrawerClose asChild>
<Button variant="secondary">닫기</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}
설명
DrawerTrigger
- Drawer를 여는 버튼 역할을 합니다.
- asChild를 사용하여 버튼을 감싸면, Button 자체가 DrawerTrigger로 작동합니다.
DrawerContent
- Drawer 내부의 컨텐츠를 표시하는 영역입니다.
- DrawerHeader, DrawerFooter 등을 포함할 수 있습니다.
DrawerHeader, DrawerFooter
- 각각 DrawerTitle, DrawerDescription, 버튼 등을 배치할 수 있습니다.
DrawerClose
- Drawer를 닫는 기능을 합니다. asChild를 사용하면 버튼 등 원하는 엘리먼트에 적용할 수 있습니다.
추가 옵션
direction="bottom", direction="left" 등을 사용하여 Drawer의 방향을 조정할 수 있습니다.
Drawer 방향 설정 예제
<Drawer direction="left">
<DrawerTrigger asChild>
<Button variant="outline">왼쪽에서 열기</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>왼쪽 Drawer</DrawerTitle>
</DrawerHeader>
</DrawerContent>
</Drawer>
정리
- Drawer를 감싸고 DrawerTrigger로 열기 버튼을 만든다.
- DrawerContent 내부에 DrawerHeader, DrawerFooter, 원하는 내용을 배치한다.
- DrawerClose를 사용하여 닫기 기능을 추가한다.
- direction 속성을 변경하면 위치를 조정할 수 있다.
※반응형 모바일 위하여 미디어쿼리 추가
https://ui.shadcn.com/docs/components/drawer
Dialog및 구성 요소를 결합하여 Drawer반응형 대화 상자를 만들 수 있습니다.
이렇게 하면 Dialog데스크톱에서 구성 요소를 렌더링하고 Drawer모바일에서 구성 요소를 렌더링합니다.
https://github.com/shadcn-ui/ui/blob/main/apps/www/hooks/use-media-query.tsx
use-media-query.tsx hook 디렉토리 에 추가
import * as React from "react"
export function useMediaQuery(query: string) {
const [value, setValue] = React.useState(false)
React.useEffect(() => {
function onChange(event: MediaQueryListEvent) {
setValue(event.matches)
}
const result = matchMedia(query)
result.addEventListener("change", onChange)
setValue(result.matches)
return () => result.removeEventListener("change", onChange)
}, [query])
return value
}
1)DashboardLayout
"use client";
import React from 'react'
import MainMenu from './components/main-menu'
import MenuTitle from './components/menu-title'
import MobileMenu from './components/mobile-menu'
import { useMediaQuery } from '@/hooks/use-media-query'
interface DashboardLayoutProps {
children: React.ReactNode
}
const DashboardLayout:React.FC<DashboardLayoutProps> = ({children}) => {
const isDesktop = useMediaQuery("(min-width: 768px)");
return (
<div className='grid md:grid-cols-[250px_1fr] px-3 md:px-0 h-screen'>
<MainMenu className="hidden md:flex" />
{!isDesktop && (
<div className='p-4 flex justify-between md:hidden sticky top-0 left-0 bg-background
border-b border-border'>
<MenuTitle />
<MobileMenu />
</div>
)}
<div className='overflow-auto py-2 px-6'>
<h1 className='pb-4 text-2xl font-bold'>환영합니다. 홍길동님!</h1>
{children}
</div>
</div>
)
}
export default DashboardLayout
2)mobile-menu.tsx
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerClose, DrawerContent,DrawerHeader, DrawerDescription, DrawerFooter,
DrawerTitle, DrawerTrigger } from "@/components/ui/drawer-100";
import { MenuIcon, X } from "lucide-react";
import MenuList from "./menu-list";
const MobileMenu:React.FC = () => {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
return (
<Drawer direction="right"
open={mobileMenuOpen}
onClose={() => setMobileMenuOpen(false)}
onOpenChange={(open)=>setMobileMenuOpen(open)}>
<DrawerTrigger asChild className="cursor-pointer">
<MenuIcon/>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader className="hidden">
<DrawerTitle>
</DrawerTitle>
<DrawerDescription></DrawerDescription>
</DrawerHeader>
<div className="p-0 h-full bg-muted">
<DrawerClose asChild className="absolute top-4 right-4">
<Button variant="ghost" className="w-10 h-10 p-2 rounded-full bg-gray-200 hover:bg-gray-300 transition-all">
<X className="w-5 h-5" />
</Button>
</DrawerClose>
<MenuList className="md:flex" />
</div>
<DrawerFooter className="hidden">
<DrawerClose asChild>
<Button variant="secondary">닫기</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
)
}
export default MobileMenu;
3)/src/app/components/common/menu-item.tsx
"use client";
import { DrawerContext } from '@/components/ui/drawer-100';
import { cn } from '@/lib/utils';
import Link from 'next/link';
import { usePathname } from 'next/navigation'
import React, { useContext } from 'react'
interface MenuItemProps {
children: React.ReactNode,
href: string
}
const MenuItem:React.FC<MenuItemProps> = ({children, href}) => {
const {onClose} = useContext(DrawerContext);
const pathname=usePathname();
const isActive = pathname === href;
return (
<li>
<Link href={href} onClick={onClose}
className={cn("block p-2 hover:bg-white dark:hover:bg-zinc-700 rounded-md text-muted-foreground text-sm font-medium",
isActive &&
"bg-primary hover:bg-primary dark:hover:bg-primary hover:text-primary-foreground text-primary-foreground ")}
>
{children}
</Link>
</li>
)
}
export default MenuItem;
4)/src/app/components/common/menu-title.tsx
import React from 'react'
const MenuTitle:React.FC = () => {
return (
<h4 className='flex items-center text-2xl'>
<span className="text-4xl font-bold text-pink-500 mr-2">M</span> Macaronics
</h4>
)
}
export default MenuTitle;
5)/src/app/components/common/menu-list.tsx
import React from "react";
import {cn} from "@/lib/utils";
import MenuItem from "./menu-item";
import MenuTitle from "./menu-title";
interface MainMenuProps {
className?: string;
}
const MenuList: React.FC<MainMenuProps> = ({className}) => {
return (
<nav className={cn(`bg-muted overflow-auto p-4 flex flex-col`,className)} >
<header className="border-b dark:border-b-black border-b-zinc-300 pb-4">
<MenuTitle />
</header>
<ul className="py-4 grow flex flex-col gap-1">
<MenuItem href="/dashboard" >대시보드</MenuItem>
<MenuItem href="/dashboard/userInfo" >유저정보</MenuItem>
<MenuItem href="/dashboard/userInfo/update">시용자업데이트</MenuItem>
<MenuItem href="/dashboard/teams">팀</MenuItem>
<MenuItem href="/dashboard/employee">직원</MenuItem>
<MenuItem href="/dashboard/account">시용자정보</MenuItem>
<MenuItem href="/dashboard/settings">설정</MenuItem>
<MenuItem href="/dashboard/nested-menu">3단메뉴</MenuItem>
<MenuItem href="/dashboard/payments">결제</MenuItem>
<MenuItem href="/products">상품</MenuItem>
<MenuItem href="/dashboard/daum-address">다음주소입력</MenuItem>
</ul>
</nav>
);
};
export default MenuList;
※모바일 메뉴 닫기(Closing) 기능 구현 설명
모바일 메뉴에서 링크를 클릭할 때 자동으로 닫히도록 구현하는 핵심적인 로직을 설명하겠습니다.
1. DrawerContext를 활용한 상태 관리
- React.createContext를 사용하여 DrawerContext를 생성했습니다.
- onClose라는 함수를 컨텍스트에서 제공하여 메뉴 항목에서 이를 호출하면 Drawer가 닫히도록 설계했습니다.
export const DrawerContext = React.createContext<{
direction?: "top" | "bottom" | "left" | "right";
onClose?: () => void;
}>({});
onClose가 정의되지 않을 수도 있기 때문에 ?(optional)로 선언
2. Drawer 컴포넌트에서 onClose 값 전달
- DrawerContext.Provider를 이용해 onClose 값을 컨텍스트에 전달
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerContext.Provider
value={{ direction: props.direction, onClose: props.onClose }}
>
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
</DrawerContext.Provider>
);
이렇게 하면 Drawer 내부의 컴포넌트들이 onClose를 사용할 수 있음
3. MenuItem에서 onClose 실행
- MenuItem에서 useContext(DrawerContext)를 통해 onClose 값을 가져와 링크 클릭 시 실행
const MenuItem: React.FC<MenuItemProps> = ({ children, href }) => {
const { onClose } = useContext(DrawerContext);
const pathname = usePathname();
const isActive = pathname === href;
return (
<li>
<Link
href={href}
onClick={onClose} // 클릭하면 onClose 실행하여 Drawer 닫기
className={cn(
"block p-2 hover:bg-white dark:hover:bg-zinc-700 rounded-md text-muted-foreground text-sm font-medium",
isActive && "bg-primary hover:bg-primary dark:hover:bg-primary hover:text-primary-foreground text-primary-foreground"
)}
>
{children}
</Link>
</li>
);
};
링크 클릭 시 onClose가 실행되면서 Drawer가 닫힘
4. onClose의 동작 원리
Drawer에서 onClose를 false로 설정하는 setState와 연결해 닫기 동작을 수행
const [isOpen, setIsOpen] = React.useState(false);
const handleClose = () => {
setIsOpen(false); // Drawer를 닫음
};
<Drawer
open={isOpen}
onClose={handleClose} // 이 값이 MenuItem까지 전달됨
onOpenChange={setIsOpen}
/>
결국 onClose를 호출하면 setIsOpen(false)가 실행되어 Drawer가 닫힘
5. onClose가 undefined인 경우 대비
- 모든 MenuItem이 Drawer 내부에서 렌더링되는 것은 아니므로 onClose가 없는 경우도 있음
- 이를 고려하여 onClose가 존재하는 경우에만 실행
<Link href={href} onClick={onClose ? onClose : undefined}>
onClose가 없으면 아무 일도 일어나지 않음
6. 드래그 닫기 지원 (onOpenChange 활용)
- Drawer를 드래그하여 닫을 경우 onOpenChange 이벤트가 실행됨
- onOpenChange에서 setIsOpen(false)를 실행하여 닫기
<Drawer
open={isOpen}
onOpenChange={(open) => setIsOpen(open)}
onClose={() => setIsOpen(false)}
/>
드래그해서 닫으면 onOpenChange(false)가 실행되면서 닫힘
정리
✅ DrawerContext를 사용하여 onClose를 제공
✅ MenuItem에서 onClose 실행하여 Drawer 닫기
✅ onClose가 undefined일 경우 대비
✅ onOpenChange를 활용하여 드래그 닫기 지원
















댓글 ( 0)
댓글 남기기