Layout
Drawer.
A bottom sheet with a grab handle that can be dragged closed. Built on vaul.
"use client";
import * as React from "react";
import { MinusIcon, PlusIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
const history = [40, 64, 32, 80, 56, 72, 48];
export default function DrawerDemo() {
const [fuel, setFuel] = React.useState(64);
return (
<Drawer>
<DrawerTrigger asChild>
<Button variant="secondary">Refuel turtle</Button>
</DrawerTrigger>
<DrawerContent>
<div className="mx-auto w-full max-w-sm">
<DrawerHeader>
<DrawerTitle>Refuel miner-02</DrawerTitle>
<DrawerDescription>Pick how much coal to load before the next run.</DrawerDescription>
</DrawerHeader>
<div className="px-5 pb-2">
<div className="flex items-center justify-center gap-4">
<Button
variant="outline"
size="icon"
onClick={() => setFuel((f) => Math.max(8, f - 8))}
disabled={fuel <= 8}
aria-label="Less coal"
>
<MinusIcon />
</Button>
<div className="flex-1 text-center">
<div className="font-display text-6xl font-medium tracking-tighter tabular-nums">{fuel}</div>
<div className="mt-1 font-mono text-[10px] tracking-[0.2em] text-muted-foreground uppercase">Coal per run</div>
</div>
<Button
variant="outline"
size="icon"
onClick={() => setFuel((f) => Math.min(128, f + 8))}
disabled={fuel >= 128}
aria-label="More coal"
>
<PlusIcon />
</Button>
</div>
<div className="mt-6 flex h-20 items-end gap-2" aria-hidden="true">
{history.map((h, i) => (
<div key={i} className="flex-1 rounded-t-sm bg-secondary" style={{ height: `${(h / 128) * 100}%` }} />
))}
<div className="flex-1 rounded-t-sm bg-primary transition-[height]" style={{ height: `${(fuel / 128) * 100}%` }} />
</div>
</div>
<DrawerFooter>
<DrawerClose asChild>
<Button>Load {fuel} coal</Button>
</DrawerClose>
<DrawerClose asChild>
<Button variant="secondary">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</div>
</DrawerContent>
</Drawer>
);
}Installation#
With the CLI
$npx lanterncn add drawerThis also installs the Lantern theme and any Lantern UI components it depends on. The shadcn CLI works directly too: npx shadcn@latest add httptim/lantern-ui/drawer. See Installation if your project is not set up yet.
Manually
Install the dependencies
npm install vaulCopy the source into your project
components/ui/drawer.tsx"use client"; import * as React from "react"; import { Drawer as DrawerPrimitive } from "vaul"; import { cn } from "@/lib/utils"; function Drawer({ ...props }: React.ComponentProps<typeof DrawerPrimitive.Root>) { return <DrawerPrimitive.Root data-slot="drawer" {...props} />; } function DrawerTrigger({ ...props }: React.ComponentProps<typeof DrawerPrimitive.Trigger>) { return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />; } function DrawerPortal({ ...props }: React.ComponentProps<typeof DrawerPrimitive.Portal>) { return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />; } function DrawerClose({ ...props }: React.ComponentProps<typeof DrawerPrimitive.Close>) { return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />; } function DrawerOverlay({ className, ...props }: React.ComponentProps<typeof DrawerPrimitive.Overlay>) { return ( <DrawerPrimitive.Overlay data-slot="drawer-overlay" className={cn( "fixed inset-0 z-50 bg-[#080b0a]/75 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0", className, )} {...props} /> ); } function DrawerContent({ className, children, ...props }: React.ComponentProps<typeof DrawerPrimitive.Content>) { return ( <DrawerPortal data-slot="drawer-portal"> <DrawerOverlay /> <DrawerPrimitive.Content data-slot="drawer-content" className={cn( "group/drawer-content fixed z-50 flex h-auto flex-col bg-popover text-popover-foreground outline-none", "data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b", "data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t", "data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm", "data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm", className, )} {...props} > <div aria-hidden="true" data-slot="drawer-handle" className="mx-auto mt-3 hidden h-1.5 w-12 shrink-0 rounded-full bg-input transition-colors group-hover/drawer-content:bg-muted-foreground group-data-[vaul-drawer-direction=bottom]/drawer-content:block" /> {children} </DrawerPrimitive.Content> </DrawerPortal> ); } function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="drawer-header" className={cn( "flex flex-col gap-1 p-5 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left", className, )} {...props} /> ); } function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) { return <div data-slot="drawer-footer" className={cn("mt-auto flex flex-col gap-2 p-5", className)} {...props} />; } function DrawerTitle({ className, ...props }: React.ComponentProps<typeof DrawerPrimitive.Title>) { return ( <DrawerPrimitive.Title data-slot="drawer-title" className={cn("font-display text-lg leading-tight font-medium tracking-tight", className)} {...props} /> ); } function DrawerDescription({ className, ...props }: React.ComponentProps<typeof DrawerPrimitive.Description>) { return ( <DrawerPrimitive.Description data-slot="drawer-description" className={cn("text-sm text-muted-foreground", className)} {...props} /> ); } export { Drawer, DrawerPortal, DrawerOverlay, DrawerTrigger, DrawerClose, DrawerContent, DrawerHeader, DrawerFooter, DrawerTitle, DrawerDescription, };Update the import paths to match your project
The source imports
cnfrom@/lib/utils.
Usage#
import {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
} from "@/components/ui/drawer";<Drawer>
<DrawerTrigger asChild>
<Button>Open</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader>
<DrawerTitle>Refuel miner-02</DrawerTitle>
<DrawerDescription>Pick how much coal to load.</DrawerDescription>
</DrawerHeader>
<DrawerFooter>
<DrawerClose asChild>
<Button variant="secondary">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>Pass direction="top", "left" or "right" to Drawer to slide from another edge. The grab handle only shows on bottom drawers.
Examples#
Dialog on desktop, drawer on mobile
"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
function useMediaQuery(query: string) {
return React.useSyncExternalStore(
(onChange) => {
const mql = window.matchMedia(query);
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
},
() => window.matchMedia(query).matches,
() => false,
);
}
export default function DrawerResponsive() {
const [open, setOpen] = React.useState(false);
const isDesktop = useMediaQuery("(min-width: 768px)");
if (isDesktop) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="secondary">Edit hub profile</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Edit hub profile</DialogTitle>
<DialogDescription>Shown on the directory. Save when you are done.</DialogDescription>
</DialogHeader>
<ProfileForm onSaved={() => setOpen(false)} />
</DialogContent>
</Dialog>
);
}
return (
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>
<Button variant="secondary">Edit hub profile</Button>
</DrawerTrigger>
<DrawerContent>
<DrawerHeader className="text-left">
<DrawerTitle>Edit hub profile</DrawerTitle>
<DrawerDescription>Shown on the directory. Save when you are done.</DrawerDescription>
</DrawerHeader>
<ProfileForm className="px-5" onSaved={() => setOpen(false)} />
<DrawerFooter className="pt-3">
<DrawerClose asChild>
<Button variant="secondary">Cancel</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}
function ProfileForm({ className, onSaved }: { className?: string; onSaved: () => void }) {
return (
<form
className={["grid gap-4", className].filter(Boolean).join(" ")}
onSubmit={(e) => {
e.preventDefault();
onSaved();
}}
>
<div className="grid gap-2">
<Label htmlFor="hub-name">Hub name</Label>
<Input id="hub-name" defaultValue="North hub" />
</div>
<div className="grid gap-2">
<Label htmlFor="hub-owner">Owner</Label>
<Input id="hub-owner" defaultValue="miner-02" />
</div>
<Button type="submit">Save profile</Button>
</form>
);
}