Overlays & Feedbackv0.1.0
Drawer
Slides from either side with backdrop blur and scroll lock.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/side-drawer.jsonManual
Install the dependencies
npm install clsx lucide-react motion radix-ui tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport type { Transition, Variants } from "motion/react"; /** Durations in seconds. */ export const duration = { instant: 0.1, quick: 0.18, base: 0.28, slow: 0.45, } as const; /** Cubic-bezier curves. */ export const ease = { /** Entering, UI response. */ out: [0.22, 1, 0.36, 1], /** Moving on screen. */ inOut: [0.65, 0, 0.35, 1], /** Sheets and the mobile menu. */ drawer: [0.32, 0.72, 0, 1], } as const; /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */ export const ui = { press: { scale: 0.96, duration: 0.12 }, icon: { type: "spring", duration: 0.3, bounce: 0 }, popover: { duration: 0.18, ease: ease.out }, modal: { duration: 0.24, ease: ease.out }, exitFactor: 0.6, } as const; /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */ export const kinetic = { heroLetter: { duration: 0.6, ease: ease.out }, /** Delay per letter, eased so the wave slows toward the end. */ heroStep: 0.035, reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 }, /** Two afterimages: lag in seconds and opacity of each. */ ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] }, } as const; /** Spring presets. */ export const spring = { snappy: { type: "spring", stiffness: 500, damping: 30 }, smooth: { type: "spring", stiffness: 260, damping: 26 }, bouncy: { type: "spring", stiffness: 300, damping: 15 }, } as const satisfies Record<string, Transition>; /** Stagger between siblings, in seconds (0.03–0.06). */ export const stagger = { tight: 0.03, base: 0.045, loose: 0.06, } as const; /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */ export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.base } }, exit: { opacity: 0, transition: { duration: duration.quick } }, }; } return { hidden: { opacity: 0, y, filter: "blur(4px)" }, visible: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: duration.base, ease: ease.out }, }, exit: { opacity: 0, y: -y / 2, filter: "blur(4px)", transition: { duration: duration.quick, ease: ease.out }, }, }; } /** Scale 0.96 → 1 for popovers, menus and dialogs. */ export function overlayVariants(reduceMotion?: boolean | null): Variants { if (reduceMotion) { return { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { duration: duration.quick } }, exit: { opacity: 0, transition: { duration: duration.instant } }, }; } return { hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" }, visible: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: duration.quick, ease: ease.out }, }, exit: { opacity: 0, scale: 0.96, filter: "blur(2px)", transition: { duration: duration.instant + 0.02, ease: ease.out }, }, }; } /** Parent variants that stagger their children in. */ export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants { return { hidden: {}, visible: { transition: { staggerChildren: gap, delayChildren } }, exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } }, }; }Copy the source
components/ui/side-drawer/side-drawer.tsx"use client"; import { useState, type ReactNode } from "react"; import { AnimatePresence, motion, useDragControls, useReducedMotion, type PanInfo } from "motion/react"; import { Dialog } from "radix-ui"; import { X } from "lucide-react"; import { cn } from "@/lib/utils"; import { duration } from "@/lib/motion"; export interface SideDrawerProps { /** Controlled open state. */ open?: boolean; /** Initial open state when uncontrolled. @default false */ defaultOpen?: boolean; /** Called when the drawer opens or closes. */ onOpenChange?: (open: boolean) => void; /** Element that opens the drawer, e.g. a button. */ trigger?: ReactNode; /** Edge the drawer slides in from. @default "right" */ side?: "left" | "right"; /** Drawer width. @default "md" */ size?: "sm" | "md" | "lg"; /** Heading; also the dialog's accessible name. */ title: ReactNode; /** Supporting text under the title. */ description?: ReactNode; /** Scrollable body. */ children?: ReactNode; /** Pinned content at the bottom, e.g. actions. */ footer?: ReactNode; /** Classes for the drawer panel. */ className?: string; } const widths = { sm: "w-[min(20rem,88vw)]", md: "w-[min(24rem,90vw)]", lg: "w-[min(32rem,92vw)]", } as const; const SLIDE = { type: "spring", stiffness: 380, damping: 38, mass: 0.9 } as const; export function SideDrawer({ open, defaultOpen = false, onOpenChange, trigger, side = "right", size = "md", title, description, children, footer, className, }: SideDrawerProps) { const [internalOpen, setInternalOpen] = useState(defaultOpen); const isOpen = open ?? internalOpen; const reduceMotion = useReducedMotion(); const dragControls = useDragControls(); const setOpen = (next: boolean) => { if (open === undefined) setInternalOpen(next); onOpenChange?.(next); }; const hidden = side === "right" ? "100%" : "-100%"; const direction = side === "right" ? 1 : -1; function onDragEnd(_: unknown, info: PanInfo) { // Swiping toward the drawer's own edge far or fast enough dismisses it. if (info.offset.x * direction > 90 || info.velocity.x * direction > 500) setOpen(false); } return ( <Dialog.Root open={isOpen} onOpenChange={setOpen}> {trigger ? <Dialog.Trigger asChild>{trigger}</Dialog.Trigger> : null} <AnimatePresence> {isOpen ? ( <Dialog.Portal forceMount> <Dialog.Overlay asChild forceMount> <motion.div className="fixed inset-0 z-50 bg-zinc-950/20 backdrop-blur-[3px]" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: duration.base }} /> </Dialog.Overlay> <Dialog.Content asChild forceMount {...(description ? {} : { "aria-describedby": undefined })}> <motion.div drag="x" dragListener={false} dragControls={dragControls} dragConstraints={{ left: 0, right: 0 }} dragElastic={side === "right" ? { left: 0.05, right: 0.7 } : { left: 0.7, right: 0.05 }} onDragEnd={onDragEnd} initial={reduceMotion ? { opacity: 0 } : { x: hidden }} animate={reduceMotion ? { opacity: 1 } : { x: 0 }} exit={reduceMotion ? { opacity: 0 } : { x: hidden }} transition={reduceMotion ? { duration: duration.quick } : SLIDE} className={cn( "fixed inset-y-0 z-50 flex flex-col bg-popover text-popover-foreground shadow-xl outline-none", side === "right" ? "right-0 border-l border-border" : "left-0 border-r border-border", widths[size], className, )} > <div onPointerDown={(event) => dragControls.start(event)} className="flex shrink-0 touch-pan-y items-start justify-between gap-4 border-b border-border px-5 py-4" > <div className="min-w-0"> <Dialog.Title className="text-base font-semibold tracking-tight text-foreground">{title}</Dialog.Title> {description ? <Dialog.Description className="mt-0.5 text-sm text-muted-foreground">{description}</Dialog.Description> : null} </div> <Dialog.Close aria-label="Close" className="-mt-1 -mr-2 inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring" > <X className="size-4" aria-hidden="true" /> </Dialog.Close> </div> <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 py-4">{children}</div> {footer ? <div className="shrink-0 border-t border-border px-5 py-4">{footer}</div> : null} </motion.div> </Dialog.Content> </Dialog.Portal> ) : null} </AnimatePresence> </Dialog.Root> ); }
Usage
import { SideDrawer } from "@/components/ui/side-drawer/side-drawer";
export function Cart() {
return (
<SideDrawer trigger={<button>Cart</button>} title="Your cart" footer={<CheckoutButton />}>
<CartItems />
</SideDrawer>
);
}API reference
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | – | Controlled open state. |
defaultOpen | boolean | false | Initial open state when uncontrolled. |
onOpenChange | (open: boolean) => void | – | Called when the drawer opens or closes. |
trigger | ReactNode | – | Element that opens the drawer, e.g. a button. |
side | "left" | "right" | "right" | Edge the drawer slides in from. |
size | "sm" | "md" | "lg" | "md" | Drawer width. |
title* | ReactNode | – | Heading; also the dialog's accessible name. |
description | ReactNode | – | Supporting text under the title. |
children | ReactNode | – | Scrollable body. |
footer | ReactNode | – | Pinned content at the bottom, e.g. actions. |
className | string | – | Classes for the drawer panel. |
- The backdrop blur and fade run on their own layer; the panel itself only animates transform.
- Swiping toward the drawer's edge past 90px, or with a quick flick, closes it.
Accessibility
- Built on Radix Dialog: focus is trapped, Escape and the close button dismiss it, page scroll is locked and focus returns to the trigger.
- The title names the dialog and the description is linked automatically when provided.
- Swipe-to-dismiss starts only from the header, so text selection and inputs in the body behave normally.
- With reduced motion enabled, the drawer fades in place instead of sliding.