All components
Drawer
Slide-in panels anchored to any viewport edge, with header, body, and footer slots.
1234567891011121314151617181920212223242526272829303132import { Drawer } from "#components/ui/base-ui/drawer.tsx";import { Button } from "#components/ui/base-ui/button.tsx";
// Base UI ships no drawer machine — this is Base UI's Dialog machine wearing// the drawer slot recipe. Popup renders the edge-aligning positioner around// Base UI's popup inside the portal.export function Example() { return ( <Drawer.Root placement="end" size="xs"> <Drawer.Trigger render={<Button variant="surface" />}> Open drawer </Drawer.Trigger> <Drawer.Portal> <Drawer.Backdrop /> <Drawer.Popup> <Drawer.Header> <Drawer.Title>Filters</Drawer.Title> <Drawer.Close aria-label="Close">×</Drawer.Close> </Drawer.Header> <Drawer.Body> <Drawer.Description> Narrow results by status, owner, or date range. </Drawer.Description> </Drawer.Body> <Drawer.Footer> <Button variant="solid">Apply</Button> </Drawer.Footer> </Drawer.Popup> </Drawer.Portal> </Drawer.Root> );}Variants
start
end
top
bottom
Sizes
xs
sm
md
lg
xl
Install
1npx stylexblocks add drawerSource
components/ui/base-ui/drawer.tsx
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335import * as stylex from "@stylexjs/stylex";import { useContext } from "react";import { Dialog as BaseDialog } from "@base-ui-components/react/dialog";import { createSlotRecipeContext } from "#lib/base-ui/create-slot-recipe-context.tsx";import { defineSlotRecipe, fromMap, resolveSlotStyles, type StyleOverrideProps,} from "#lib/recipe-props.ts";import { semanticRadii } from "@stylexblocks/ui/semantic-tokens/radii.stylex.ts";import { semanticShadows } from "@stylexblocks/ui/semantic-tokens/shadows.stylex.ts";import { durations } from '@stylexblocks/ui/tokens/durations.stylex.ts'import { fontSizes } from '@stylexblocks/ui/tokens/font-sizes.stylex.ts'import { fontWeights } from '@stylexblocks/ui/tokens/font-weights.stylex.ts'import { lineHeights } from '@stylexblocks/ui/tokens/line-heights.stylex.ts'import { semanticColors } from '@stylexblocks/ui/semantic-tokens/colors.stylex.ts'import { sizes } from '@stylexblocks/ui/tokens/sizes.stylex.ts'import { spacing } from '@stylexblocks/ui/tokens/spacing.stylex.ts'import { zIndices } from '@stylexblocks/ui/tokens/z-indices.stylex.ts'
const drawerBackdrop = stylex.create({ base: { position: 'fixed', insetInlineStart: 0, top: 0, width: '100vw', height: '100dvh', zIndex: zIndices.layer4, animationName: { default: null, ':is([data-open], [data-state="open"])': 'fade-in', ':is([data-closed], [data-state="closed"])': 'fade-out', }, animationDuration: { default: null, ':is([data-open], [data-state="open"])': durations.moderate, ':is([data-closed], [data-state="closed"])': durations.faster, }, },})
const drawerPositioner = stylex.create({ base: { '--drawer-z-index': zIndices.layer4, display: 'flex', width: '100vw', height: '100dvh', position: 'fixed', padding: spacing._2, insetInlineStart: 0, top: 0, zIndex: 'calc(var(--drawer-z-index) + var(--layer-index, 0))', },})
const drawerPositionerPlacements = stylex.create({ start: { justifyContent: 'flex-start', }, end: { justifyContent: 'flex-end', }, top: { alignItems: 'flex-start', }, bottom: { alignItems: 'flex-end', },})
const drawerPositionerAttacheds = stylex.create({ true: { padding: 0, },})
const drawerContent = stylex.create({ base: { display: 'flex', flexDirection: 'column', position: 'relative', width: '100%', outline: 'none', zIndex: 'calc(var(--drawer-z-index) + var(--layer-index, 0))', fontSize: fontSizes.sm, lineHeight: lineHeights.sm, maxHeight: '100dvh', color: 'inherit', animationDuration: { default: null, ':is([data-open], [data-state="open"])': durations.moderate, ':is([data-closed], [data-state="closed"])': durations.faster, }, },})
const drawerContentSizes = stylex.create({ xs: { maxWidth: sizes.xs, }, sm: { maxWidth: sizes.md, }, md: { maxWidth: sizes.lg, }, lg: { maxWidth: '2xl', }, xl: { maxWidth: '4xl', }, full: { maxWidth: '100vw', height: '100dvh', },})
const drawerContentPlacements = stylex.create({ start: { animationName: { default: null, ':is([data-open], [data-state="open"])': 'slide-from-left-full', ':is([data-closed], [data-state="closed"])': 'slide-to-left-full', }, }, end: { animationName: { default: null, ':is([data-open], [data-state="open"])': 'slide-from-right-full', ':is([data-closed], [data-state="closed"])': 'slide-to-right-full', }, }, top: { maxWidth: '100%', animationName: { default: null, ':is([data-open], [data-state="open"])': 'slide-from-top-full', ':is([data-closed], [data-state="closed"])': 'slide-to-top-full', }, }, bottom: { maxWidth: '100%', animationName: { default: null, ':is([data-open], [data-state="open"])': 'slide-from-bottom-full', ':is([data-closed], [data-state="closed"])': 'slide-to-bottom-full', }, },})
const drawerContentAttacheds = stylex.create({ true: { borderRadius: 'none', },})
const drawerHeader = stylex.create({ base: { display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', flex: 0, paddingInline: spacing._6, paddingBlock: spacing._4, },})
const drawerBody = stylex.create({ base: { paddingInline: spacing._6, paddingBlock: spacing._2, flex: 1, overflow: 'auto', },})
const drawerFooter = stylex.create({ base: { display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: spacing._3, paddingInline: spacing._6, paddingTop: spacing._2, paddingBottom: spacing._4, },})
const drawerTitle = stylex.create({ base: { fontSize: fontSizes.lg, lineHeight: lineHeights.lg, fontWeight: fontWeights.medium, },})
const drawerDescription = stylex.create({ base: { color: semanticColors.fgMuted, },})
const drawerCloseTrigger = stylex.create({ base: { position: 'absolute', top: spacing._4, right: spacing._4, },})
export type DrawerSize = keyof typeof drawerContentSizes
export type DrawerPlacement = keyof typeof drawerPositionerPlacements
export type DrawerAttached = keyof typeof drawerPositionerAttacheds
// defineSlotRecipe validates the shape at compile time: default values must// exist in the axis maps, and slot keys must be `styles` or a declared axis.// Axis maps are looked up by pluralized axis name (`placement` → `placements`).const drawerRecipe = defineSlotRecipe({ slots: { backdrop: { styles: drawerBackdrop }, positioner: { styles: drawerPositioner, placements: drawerPositionerPlacements, }, content: { styles: drawerContent, sizes: drawerContentSizes, placements: drawerContentPlacements, }, header: { styles: drawerHeader }, body: { styles: drawerBody }, footer: { styles: drawerFooter }, title: { styles: drawerTitle }, description: { styles: drawerDescription }, closeTrigger: { styles: drawerCloseTrigger }, }, defaultVariants: { size: "xs", placement: "end" },});
// Local chrome the recipe leaves to the composition layer: the backdrop's// fixed fill + wash and the panel's surface (plus real max-widths for the// `lg`/`xl` steps, whose generated values are Chakra size names).const chrome = stylex.create({ backdrop: { position: "fixed", inset: 0, backgroundColor: semanticColors.bgBackdrop, }, content: { backgroundColor: semanticColors.bgPanel, borderWidth: "1px", borderStyle: "solid", borderColor: semanticColors.border, borderRadius: semanticRadii.panel, boxShadow: semanticShadows.overlay, },});
const contentSizeFix = stylex.create({ lg: { maxWidth: "42rem" }, xl: { maxWidth: "56rem" },});
const { withRootProvider, withContext, VariantContext } = createSlotRecipeContext(drawerRecipe);
const PopupElement = BaseDialog.Popup as React.ElementType;
/** * Base UI has no positioner part, so Popup renders the recipe's edge-aligning * positioner around the engine popup — clicking the positioner counts as an * outside press and dismisses. */function Popup( allProps: React.ComponentProps<typeof BaseDialog.Popup> & StyleOverrideProps,) { const { palette, style, className, ...rest } = allProps as Record<string, unknown>; const v = useContext(VariantContext); const positioner = stylex.props( ...(resolveSlotStyles(drawerRecipe.slots.positioner, v) as ReadonlyArray<never>), ); const sx = stylex.props( ...([ palette, ...resolveSlotStyles(drawerRecipe.slots.content, v), chrome.content, fromMap(contentSizeFix, v.size), style, ] as unknown as ReadonlyArray<never>), ); return ( <div {...positioner}> <PopupElement {...rest} className={ typeof className === "string" && className.length > 0 ? [sx.className, className].filter(Boolean).join(" ") : sx.className } style={sx.style} /> </div> );}Popup.displayName = "DrawerPopup";
/** * Drawer — Base UI ships no drawer machine, so this is Base UI's dialog * machine wearing the drawer slot recipe. `<Drawer.Root size placement>` * resolves the recipe once; every part inherits the variants through context * and composes via `render`. */export const Drawer = { Root: withRootProvider(BaseDialog.Root, "DrawerRoot"), Trigger: BaseDialog.Trigger, Portal: BaseDialog.Portal, Backdrop: withContext( BaseDialog.Backdrop, (v) => [...resolveSlotStyles(drawerRecipe.slots.backdrop, v), chrome.backdrop], "DrawerBackdrop", ), Popup, Header: withContext("div", "header", "DrawerHeader"), Body: withContext("div", "body", "DrawerBody"), Footer: withContext("div", "footer", "DrawerFooter"), Title: withContext(BaseDialog.Title, "title", "DrawerTitle"), Description: withContext(BaseDialog.Description, "description", "DrawerDescription"), Close: withContext(BaseDialog.Close, "closeTrigger", "DrawerClose"),};