docs / core / drawer
Drawer
the same anatomy as Dialog, pinned to an edge instead of centered. a panel slides in from any side; a bottom sheet is just side="bottom".
intensity · recedesrsc · client
provenance
shaped from personal-website · sisu-pluslayer
core / primitivesintensity
recedes · safe anywhere, including dense surfacescomposition
settings panels, widget libraries, filters that keep the page visible behind the scrimthe slide-up sheet is this primitive with side="bottom"not navigation chrome. a persistent sidebar is layout, not a Drawernever two edges open at oncea11y
role="dialog" named by its Title · focus is trapped, Escape closes · Drawer.Close keeps a 44px minimum targetjest-axedependencies
@base-ui/react · Card from the same packagelive demo · try it out
customize
sidewhich edge it pins to
sizeinline size, or block size on horizontal edges
surfacehow the panel sits above the scrim
highlightwash, inner edge, or glow ring
titlethe heading that names the dialog
descriptionsupporting line under the title
usage
import { Button } from "usva/primitives/button";
import { Drawer } from "usva/primitives/drawer";
<Drawer>
<Drawer.Trigger render={<Button>Open</Button>} />
<Drawer.Content>
<Drawer.Title>Widget library</Drawer.Title>
<Drawer.Description>Drag a widget onto the grid to add it.</Drawer.Description>
<Drawer.Close render={<Button variant="ghost">Done</Button>} />
</Drawer.Content>
</Drawer>props
| prop | type | default | notes |
|---|---|---|---|
| side | "top" | "right" | "bottom" | "left" | "right" | which edge the panel is pinned to. |
| size | "sm" | "md" | "lg" | "md" | inline size on the vertical edges, block size on the horizontal ones. |
| surface | "elevated" | "flat" | "glass" | "elevated" | how the panel sits above the scrim. shared with Card and Dialog. |
| highlight | "none" | "wash" | "edge" | "ring" | "none" | accent treatment on Drawer.Content: a wash, a top edge hairline, or a glow ring. the same vocabulary as Card and Dialog. |
| backdropClassName | string | — | extra classes on the scrim, on Drawer.Content. |
get it
npx shadcn add https://usva.build/r/drawer.jsonsource · components/ui/drawer.tsxexactly what this command copies
"use client";
import { Dialog as Base } from "@base-ui/react/dialog";
import * as React from "react";
import { cn } from "@/lib/utils";
import {
type CardHighlight,
type CardSurface,
SURFACE_ELEVATED,
SURFACE_SKIN,
} from "./card";
import { useScopedTheme } from "./use-scoped-theme";
export type DrawerSide = "top" | "right" | "bottom" | "left";
export type DrawerSize = "sm" | "md" | "lg";
export type DrawerProps = Base.Root.Props;
function DrawerRoot(props: DrawerProps) {
return <Base.Root {...props} />;
}
DrawerRoot.displayName = "Drawer";
export const DrawerTrigger = Base.Trigger;
/** Edge anchoring, plus the border and radius on the inner edge only. */
const sideAnchor: Record<DrawerSide, string> = {
right: "inset-y-0 right-0 h-full border-l rounded-l-2xl",
left: "inset-y-0 left-0 h-full border-r rounded-r-2xl",
top: "inset-x-0 top-0 w-full border-b rounded-b-2xl",
bottom: "inset-x-0 bottom-0 w-full border-t rounded-t-2xl",
};
/** Inline size on the vertical edges, block size on the horizontal ones. */
const sideSize: Record<DrawerSide, Record<DrawerSize, string>> = {
right: {
sm: "w-full max-w-xs",
md: "w-full max-w-md",
lg: "w-full max-w-2xl",
},
left: {
sm: "w-full max-w-xs",
md: "w-full max-w-md",
lg: "w-full max-w-2xl",
},
top: { sm: "max-h-[30vh]", md: "max-h-[50vh]", lg: "max-h-[80vh]" },
bottom: { sm: "max-h-[30vh]", md: "max-h-[50vh]", lg: "max-h-[80vh]" },
};
const sideEnter: Record<DrawerSide, string> = {
right:
"data-[starting-style]:translate-x-full data-[ending-style]:translate-x-full",
left: "data-[starting-style]:-translate-x-full data-[ending-style]:-translate-x-full",
top: "data-[starting-style]:-translate-y-full data-[ending-style]:-translate-y-full",
bottom:
"data-[starting-style]:translate-y-full data-[ending-style]:translate-y-full",
};
export type DrawerContentProps = React.ComponentPropsWithoutRef<
typeof Base.Popup
> & {
side?: DrawerSide;
size?: DrawerSize;
backdropClassName?: string;
/** How the panel sits above the scrim. Defaults to elevated. */
surface?: CardSurface;
/** Accent treatment, shared with Card: a wash, an inner edge, or a glow ring. */
highlight?: CardHighlight;
};
/**
* Base UI owns focus trap, scroll lock, Esc, and portalling. This adds only the
* edge anchoring and the slide, both CSS. kajo's slide-up sheet is side="bottom".
*/
export const DrawerContent = React.forwardRef<
HTMLDivElement,
DrawerContentProps
>(
(
{
className,
backdropClassName,
side = "right",
size = "md",
surface = "elevated",
highlight = "none",
children,
...props
},
ref,
) => {
const [probe, scopedTheme] = useScopedTheme();
return (
<>
<span ref={probe} hidden />
<Base.Portal>
<Base.Backdrop
data-theme={scopedTheme}
className={cn(
"fixed inset-0 z-overlay transition-opacity duration-base motion-reduce:transition-none",
"data-[starting-style]:opacity-0 data-[ending-style]:opacity-0",
surface === "glass"
? "bg-scrim/40 backdrop-blur-[3px]"
: "bg-scrim backdrop-blur-sm",
backdropClassName,
)}
/>
<Base.Popup
ref={ref}
data-theme={scopedTheme}
data-side={side}
data-surface={surface}
data-highlight={highlight !== "none" ? highlight : undefined}
className={cn(
"fixed z-overlay flex flex-col overflow-y-auto",
"isolate border-border p-6 text-ink",
sideAnchor[side],
sideSize[side][size],
SURFACE_SKIN[surface],
highlight === "ring"
? "glow-ring"
: SURFACE_ELEVATED[surface] && "shadow-overlay",
highlight === "wash" && "wash-accent",
highlight === "edge" &&
"after:absolute after:inset-x-6 after:top-0 after:h-px after:hairline-accent",
"transition-enter duration-[350ms] ease-spring",
"motion-reduce:transition-none motion-reduce:transform-none",
"data-[starting-style]:opacity-0 data-[ending-style]:opacity-0",
"data-[ending-style]:duration-base data-[ending-style]:ease-soft",
sideEnter[side],
className,
)}
{...props}
>
{children}
</Base.Popup>
</Base.Portal>
</>
);
},
);
DrawerContent.displayName = "DrawerContent";
export const DrawerTitle = React.forwardRef<
HTMLHeadingElement,
React.ComponentPropsWithoutRef<typeof Base.Title>
>(({ className, ...props }, ref) => (
<Base.Title
ref={ref}
className={cn(
"text-balance text-lg font-semibold tracking-[-0.01em] text-ink",
className,
)}
{...props}
/>
));
DrawerTitle.displayName = "DrawerTitle";
export const DrawerDescription = React.forwardRef<
HTMLParagraphElement,
React.ComponentPropsWithoutRef<typeof Base.Description>
>(({ className, ...props }, ref) => (
<Base.Description
ref={ref}
className={cn("mt-1 text-pretty text-sm text-muted", className)}
{...props}
/>
));
DrawerDescription.displayName = "DrawerDescription";
export const DrawerClose = React.forwardRef<
HTMLButtonElement,
React.ComponentPropsWithoutRef<typeof Base.Close>
>(({ className, ...props }, ref) => (
<Base.Close
ref={ref}
className={cn(
"inline-flex min-h-11 min-w-11 items-center justify-center rounded-md text-muted outline-none",
"transition-control duration-fast ease-soft",
"hover:bg-surface-2 hover:text-ink active:scale-[0.96] focus-visible:ring-focus",
"motion-reduce:transition-none motion-reduce:transform-none",
className,
)}
{...props}
/>
));
DrawerClose.displayName = "DrawerClose";
export const Drawer = Object.assign(DrawerRoot, {
Trigger: DrawerTrigger,
Content: DrawerContent,
Title: DrawerTitle,
Description: DrawerDescription,
Close: DrawerClose,
});