Styled drawer
Five drawers: bottom, top, glass, rounded and snap. Each is self-contained (no radix or vaul), sized, token-driven, and closes on backdrop click or Escape.
Installation
The styled layer is free and installs like any other ai2 component.
Run the following command
npx shadcn@latest add @ai2/drawer-styledDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install motionCopy the source
components/ui/drawer-styled.tsx"use client"
import * as React from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
/* Drawer family: 5 decorative bottom/top drawers. Each export is a complete drawer:
internal open/closed state (uncontrolled defaultOpen or controlled
open/onOpenChange), NO radix/vaul/portal - a self-contained fixed backdrop + a
fixed panel pinned to the edge, sliding in. It closes on a backdrop click, Escape
or a click on the grab handle. The panel takes focus on open. AnimatePresence
handles the slide; only a fade under reduced motion. There is a visible token
handle bar at the top/bottom end of the panel. size = panel height. Color comes
ONLY from tokens, via alpha color-mix. */
export type StyledSize = "sm" | "md" | "lg" | "xl"
const panelHeight: Record<StyledSize, string> = {
sm: "h-[35vh]",
md: "h-[50vh]",
lg: "h-[68vh]",
xl: "h-[82vh]",
}
interface DrawerProps {
size?: StyledSize
trigger?: React.ReactNode
title?: React.ReactNode
children?: React.ReactNode
className?: string
open?: boolean
defaultOpen?: boolean
onOpenChange?: (o: boolean) => void
}
type Edge = "bottom" | "top"
const backdropClass =
"fixed inset-0 z-50 bg-[color-mix(in_oklab,var(--color-foreground)_50%,transparent)]"
const panelBase =
"fixed inset-x-0 z-50 flex flex-col border-border bg-card text-card-foreground shadow-lg outline-none"
const triggerBtn =
"inline-flex h-9 shrink-0 select-none items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-border bg-secondary px-4 text-sm font-medium text-secondary-foreground outline-none transition-colors hover:bg-[color-mix(in_oklab,var(--color-foreground)_6%,transparent)] focus-visible:ring-[3px] focus-visible:ring-ring/50"
const handleBtn =
"flex w-full shrink-0 cursor-pointer items-center justify-center py-3 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-inset"
const handleBar =
"h-1.5 w-12 rounded-full bg-[color-mix(in_oklab,var(--color-foreground)_25%,transparent)]"
/* Controlled/uncontrolled acik durum yonetimi. */
function useDrawerState(props: DrawerProps) {
const { open, defaultOpen, onOpenChange } = props
const isControlled = open !== undefined
const [internal, setInternal] = React.useState(defaultOpen ?? false)
const isOpen = isControlled ? open : internal
const setOpen = React.useCallback(
(next: boolean) => {
if (!isControlled) setInternal(next)
onOpenChange?.(next)
},
[isControlled, onOpenChange]
)
return { isOpen, setOpen }
}
/* Shared shell: trigger plus an AnimatePresence backdrop plus a panel pinned to the edge. edge determines the placement, the handle position and the slide direction. */
function DrawerShell({
props,
edge,
heightClass,
panelClassName,
backdropClassName,
}: {
props: DrawerProps
edge: Edge
heightClass?: string
panelClassName?: string
backdropClassName?: string
}) {
const { size = "md", trigger, title, children, className } = props
const { isOpen, setOpen } = useDrawerState(props)
const reduce = useReducedMotion()
const panelRef = React.useRef<HTMLDivElement>(null)
React.useEffect(() => {
if (!isOpen) return
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false)
}
window.addEventListener("keydown", onKey)
const id = window.requestAnimationFrame(() => panelRef.current?.focus())
return () => {
window.removeEventListener("keydown", onKey)
window.cancelAnimationFrame(id)
}
}, [isOpen, setOpen])
const offscreen = edge === "bottom" ? "100%" : "-100%"
const slide = {
initial: { y: offscreen },
animate: { y: 0 },
exit: { y: offscreen },
}
const fade = { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }
const motionProps = reduce ? fade : slide
const handle = (
<button
type="button"
aria-label="Close drawer"
className={handleBtn}
onClick={() => setOpen(false)}
>
<span className={handleBar} />
</button>
)
return (
<>
{/* The ARIA state lives on the trigger ITSELF. This file never had a `role="button"` wrapper, but it had no aria-haspopup or aria-expanded either: a screen-reader user had NO idea the button opened a dialog or whether it was open. It was caught during a live AX measurement - it was missing from the first list because the file was not found by a `role="button"` search. */}
<span data-slot="styled-drawer-trigger" className="inline-flex">
{React.isValidElement<React.ButtonHTMLAttributes<HTMLButtonElement>>(trigger) ? (
React.cloneElement(trigger, {
"aria-haspopup": "dialog",
"aria-expanded": isOpen,
onClick: (event: React.MouseEvent<HTMLButtonElement>) => {
trigger.props.onClick?.(event)
setOpen(true)
},
})
) : (
<button
type="button"
aria-haspopup="dialog"
aria-expanded={isOpen}
onClick={() => setOpen(true)}
className={triggerBtn}
>
{trigger ?? "Open"}
</button>
)}
</span>
<AnimatePresence>
{isOpen ? (
<motion.div
data-slot="styled-drawer-backdrop"
className={cn(backdropClass, backdropClassName)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, ease: "easeOut" }}
onClick={() => setOpen(false)}
>
<motion.div
ref={panelRef}
data-slot="styled-drawer"
role="dialog"
aria-modal="true"
tabIndex={-1}
className={cn(panelBase, edge === "bottom" ? "bottom-0" : "top-0", heightClass ?? panelHeight[size], panelClassName, className)}
initial={motionProps.initial}
animate={motionProps.animate}
exit={motionProps.exit}
transition={reduce ? { duration: 0.2, ease: "easeOut" } : { type: "spring", stiffness: 320, damping: 34 }}
onClick={(e) => e.stopPropagation()}
>
{edge === "bottom" ? handle : null}
{title ? (
<div className="shrink-0 px-6 pb-2 pt-1 text-base font-semibold">{title}</div>
) : null}
<div className="flex-1 overflow-y-auto px-6 py-4 text-sm text-muted-foreground">
{children}
</div>
{edge === "top" ? handle : null}
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
</>
)
}
/* Bottom: alt kenardan yukari kayar, ust koseleri yuvarlak, ustte tutamac. */
export function BottomDrawer({
size = "md",
trigger,
title,
children,
className,
open,
defaultOpen,
onOpenChange,
}: DrawerProps) {
return (
<DrawerShell
props={{ size, trigger, title, children, className, open, defaultOpen, onOpenChange }}
edge="bottom"
panelClassName="rounded-t-2xl border-t"
/>
)
}
/* Top: ust kenardan asagi kayar, alt koseleri yuvarlak, altta tutamac. */
export function TopDrawer({
size = "md",
trigger,
title,
children,
className,
open,
defaultOpen,
onOpenChange,
}: DrawerProps) {
return (
<DrawerShell
props={{ size, trigger, title, children, className, open, defaultOpen, onOpenChange }}
edge="top"
panelClassName="rounded-b-2xl border-b"
/>
)
}
/* Glass: alt cekmece, buzlu cam panel + backdrop-blur. */
export function GlassDrawer({
size = "md",
trigger,
title,
children,
className,
open,
defaultOpen,
onOpenChange,
}: DrawerProps) {
return (
<DrawerShell
props={{ size, trigger, title, children, className, open, defaultOpen, onOpenChange }}
edge="bottom"
backdropClassName="bg-[color-mix(in_oklab,var(--color-foreground)_40%,transparent)] supports-[backdrop-filter]:backdrop-blur-sm"
panelClassName="rounded-t-2xl border-t bg-[color-mix(in_oklab,var(--color-card)_75%,transparent)] supports-[backdrop-filter]:bg-[color-mix(in_oklab,var(--color-card)_55%,transparent)] supports-[backdrop-filter]:backdrop-blur-xl shadow-[inset_0_1px_0_color-mix(in_oklab,var(--color-foreground)_12%,transparent)]"
/>
)
}
/* Rounded: alt cekmece, kenarlardan bosluklu yuzen panel, cok yuvarlak koseler. */
export function RoundedDrawer({
size = "md",
trigger,
title,
children,
className,
open,
defaultOpen,
onOpenChange,
}: DrawerProps) {
return (
<DrawerShell
props={{ size, trigger, title, children, className, open, defaultOpen, onOpenChange }}
edge="bottom"
panelClassName="inset-x-3 bottom-3 rounded-3xl border"
/>
)
}
/* Snap: a bottom drawer, a longer panel that reads like a snap point (open/close
only). */
export function SnapDrawer({
size = "md",
trigger,
title,
children,
className,
open,
defaultOpen,
onOpenChange,
}: DrawerProps) {
return (
<DrawerShell
props={{ size, trigger, title, children, className, open, defaultOpen, onOpenChange }}
edge="bottom"
heightClass="h-[88vh]"
panelClassName="rounded-t-2xl border-t"
/>
)
}Manual installs skip the @ai2/tokens theme, so add the token CSS from the theming guide or the tone colors will be missing.
Variations
5 takes on the same idea. Each is its own export, and every one accepts a size prop (sm, md, lg, xl) aligned to the base Button scale.
Bottom
Slides up from the bottom with a grab handle.
import { BottomDrawer } from "@/components/ui/drawer-styled"
<BottomDrawer title="Drawer title">A self-contained drawer with a grab handle. Click the backdrop or press Escape to close.</BottomDrawer>Top
Slides down from the top.
import { TopDrawer } from "@/components/ui/drawer-styled"
<TopDrawer title="Drawer title">A self-contained drawer with a grab handle. Click the backdrop or press Escape to close.</TopDrawer>Glass
A frosted glass drawer with a blurred backdrop.
import { GlassDrawer } from "@/components/ui/drawer-styled"
<GlassDrawer title="Drawer title">A self-contained drawer with a grab handle. Click the backdrop or press Escape to close.</GlassDrawer>Rounded
A floating drawer inset from the edges with large corners.
import { RoundedDrawer } from "@/components/ui/drawer-styled"
<RoundedDrawer title="Drawer title">A self-contained drawer with a grab handle. Click the backdrop or press Escape to close.</RoundedDrawer>Snap
A taller drawer that reads as a snap point.
import { SnapDrawer } from "@/components/ui/drawer-styled"
<SnapDrawer title="Drawer title">A self-contained drawer with a grab handle. Click the backdrop or press Escape to close.</SnapDrawer>ai2 Styled drawer: 5 styled variations on the token system
The ai2 Styled drawer are a set of 5 decorative button variations from the styled layer of the @ai2 design system, built around edge drawers with a grab handle. They are free and MIT licensed, and every color comes from a semantic token, so they theme with the rest of ai2 in light and dark.
Motion runs on framer-motion: framer-motion slides the drawer in from its edge; the backdrop fades. The styled layer is opt-in, so the dependency only lands if you use it; the base components stay lean. Under reduced motion, the slide is skipped and the drawer fades or shows instantly.
What is in the ai2 Styled drawer?
5 exports in one file: Bottom, Top, Glass, Rounded and Snap. Each renders a native button and takes a size prop (sm, md, lg, xl) aligned to the base Button. They are separate from the base Button on purpose: the base keeps its clean variant, tone and size axes, while the styled layer carries the effects.
You own the file. Copy the one category file and you have all 5 variations, with no runtime dependency on ai2 itself.
Why use it
- On-system by construction: Every color resolves to an ai2 semantic token, so the buttons follow your theme in light and dark with no extra work.
- Effect without the sprawl: The decorations live in a dedicated styled file, so the base Button keeps its clean, predictable API.
- Accessible and honest: Each renders a real button element, keeps a visible focus ring, and respects prefers-reduced-motion.
Features
- Token-driven color: No hardcoded hex or oklch; the look recolors with your theme tokens.
- framer-motion: framer-motion slides the drawer in from its edge; the backdrop fades.
- Reduced-motion aware: Under prefers-reduced-motion, the slide is skipped and the drawer fades or shows instantly.
- Size aligned to the base: Every variation takes sm, md, lg and xl matching the base Button height scale, so styled and base buttons line up in a row.
Production tips
- Use it for emphasis, not everywhere: Styled buttons draw the eye. Reserve them for the one action you want people to take on a screen, and use the base Button for the rest.
- Keep labels as verbs: The decoration adds weight, so a clear action label keeps the button scannable.
- Pick one variation per surface: The variations share a family; using two different ones in the same view competes for attention.
Works with the rest of ai2
The Styled drawer sit alongside the base Button and the rest of the @ai2 registry. They share the same token file, so a styled action next to a base button or a badge stays visually consistent in both modes.