package examples import (package examples import ( "github.com/axadrn/shadcn-templ/v2/components/badge" "github.com/axadrn/shadcn-templ/v2/components/button" "github.com/axadrn/shadcn-templ/v2/components/drawer" "github.com/axadrn/shadcn-templ/v2/components/field" "github.com/axadrn/shadcn-templ/v2/components/radiogroup") type drawerDemoTime struct { Value string ID string Label string Description string Badge string} var drawerDemoTimes = []drawerDemoTime{ {Value: "asap", ID: "delivery-asap", Label: "Standard delivery", Description: "25–35 min · Driver assigned now", Badge: "Fastest"}, {Value: "5-00", ID: "delivery-5-00", Label: "5:00 PM – 5:15 PM", Description: "Prep starts at 4:45 PM"}, {Value: "5-30", ID: "delivery-5-30", Label: "5:30 PM – 5:45 PM", Description: "Good if you're heading home"}, {Value: "6-00", ID: "delivery-6-00", Label: "6:00 PM – 6:15 PM", Description: "Most popular · High demand"}, {Value: "6-30", ID: "delivery-6-30", Label: "6:30 PM – 6:45 PM", Description: "Last slot before kitchen closes"},} templ DrawerDemo() { @drawer.Drawer(drawer.Props{ID: "drawer-demo", SwipeDirection: drawer.SwipeDirectionRight, ShowSwipeHandle: true}) { @button.Button(button.Props{Variant: button.VariantSecondary, Attributes: drawer.Trigger(ctx)}) { Open Drawer } @drawer.Content() { @drawer.Header() { @drawer.Title() { Pick a delivery time } @drawer.Description() { We'll prepare your order as soon as possible. } } <div class="flex-1 scroll-fade overflow-y-auto p-4"> @radiogroup.RadioGroup(radiogroup.Props{Value: "asap", Class: "gap-2"}) { for _, time := range drawerDemoTimes { @field.Label(field.LabelProps{For: time.ID}) { @field.Field(field.Props{Orientation: field.OrientationHorizontal}) { @field.Content() { @field.Title(field.TitleProps{Class: "flex items-center gap-2"}) { { time.Label } if time.Badge != "" { @badge.Badge(badge.Props{Variant: badge.VariantSecondary}) { { time.Badge } } } } @field.Description() { { time.Description } } } @radiogroup.Item(radiogroup.ItemProps{ID: time.ID, Value: time.Value}) } } } } </div> @drawer.Footer() { @button.Button(button.Props{ID: "drawer-demo-confirm", Class: "h-[34px]"}) { Confirm Delivery Time } @button.Button(button.Props{Variant: button.VariantOutline, Attributes: drawer.Close(ctx)}) { Cancel } } } } <script nonce={ templ.GetNonce(ctx) }> (() => { // The dialog waits inside its portal <template> until drawer.js // lifts it to <body>. The template copy wins over a stale portaled // copy from a previous htmx swap; attributes and listeners set on // it survive the lift (same node). const drawerDemo = () => { for (const tpl of document.querySelectorAll("template[data-tui-drawer-portal]")) { const content = tpl.content.getElementById("drawer-demo"); if (content) return content; } return document.getElementById("drawer-demo"); }; // useIsMobile pendant (hooks/use-mobile.ts): swipeSwipeDirection is // down and the swipe handle shows below the md breakpoint, right // and no handle from md up. Demo-only behavior, so it lives here // instead of the component API. The direction attributes live on // the popup (Base UI Drawer.Popup). const mq = window.matchMedia("(max-width: 767px)"); const apply = () => { const dialog = drawerDemo(); const popup = dialog?.querySelector('[data-tui-drawer-popup]'); if (!popup) return; popup.setAttribute("data-swipe-direction", mq.matches ? "down" : "right"); popup.setAttribute("data-swipe-axis", mq.matches ? "y" : "x"); const handle = popup.querySelector('[data-slot="drawer-swipe-handle"]'); // Inline display, because the handle's flex class would win // over the [hidden] UA rule. if (handle) handle.style.display = mq.matches ? "" : "none"; }; apply(); mq.addEventListener("change", apply); // handleConfirm pendant: close the drawer, then confirm the // selected slot via toast. const labels = { "asap": "Standard delivery", "5-00": "5:00 PM – 5:15 PM", "5-30": "5:30 PM – 5:45 PM", "6-00": "6:00 PM – 6:15 PM", "6-30": "6:30 PM – 6:45 PM", }; const confirm = drawerDemo()?.querySelector("#drawer-demo-confirm"); confirm?.addEventListener("click", () => { const dialog = drawerDemo(); const selected = dialog?.querySelector("input[data-tui-radio-input]:checked"); if (!selected || !(selected.value in labels)) return; window.tui.drawer.close(dialog); window.tui.toast.add({ title: "Delivery time confirmed", description: labels[selected.value], }); }); })(); </script>}Installation
Usage
Composition
Use the following composition to build a Drawer:
drawer.Content composes the portal, overlay, viewport, and popup: the script portals the panel to body, the native dialog plays the viewport role, and a dedicated overlay element renders behind the panel. For lower-level control, drawer.SwipeHandle is also exported.
Custom Sizes
A vertical drawer sizes itself to its content and is capped at calc(100dvh - 6rem) by default. A side drawer spans 75% of the viewport width, or 24rem on larger screens.
To customize the height of a vertical drawer, use the h-* and max-h-* utilities via Class on drawer.Content.
To customize the width of a side drawer, use the w-* and max-w-* utilities via Class on drawer.Content.
When the same component renders in multiple directions, scope an override to one axis using the data-[swipe-axis=*] variants.
To make a region of the drawer scrollable, make the scroll container a flex item. Avoid h-full, which does not resolve inside a content-sized drawer.
Styling
The drawer exposes CSS variables for style-level customization. Set the sizing variables via Class on drawer.Content. Set the overlay variable on [data-slot=drawer-overlay] in your CSS.
The drawer also sets data attributes you can target with variants such as data-[swipe-direction=down]: on drawer.Content, or group-data-[swipe-axis=y]/drawer-popup: on its descendants.
Position
Use the SwipeDirection prop on drawer.Drawer to set the side of the drawer.
Available options are SwipeDirectionUp, SwipeDirectionRight, SwipeDirectionDown, and SwipeDirectionLeft. The default is SwipeDirectionDown.
package examples import (package examples import ( "github.com/axadrn/shadcn-templ/v2/components/button" "github.com/axadrn/shadcn-templ/v2/components/drawer") templ DrawerSides() { @drawer.Drawer(drawer.Props{SwipeDirection: drawer.SwipeDirectionLeft}) { @button.Button(button.Props{Variant: button.VariantSecondary, Attributes: drawer.Trigger(ctx)}) { Open Left Drawer } @drawer.Content() { @drawer.Header() { @drawer.Title() { Move Goal } @drawer.Description() { Set your daily activity goal. } } <div class="flex-1 p-4"> <div class="size-full rounded-2xl bg-muted"></div> </div> @drawer.Footer() { @button.Button(button.Props{Attributes: drawer.Close(ctx)}) { Close } } } }}Swipe Handle
Use ShowSwipeHandle on drawer.Drawer to render a swipe handle.
package examples import (package examples import ( "github.com/axadrn/shadcn-templ/v2/components/button" "github.com/axadrn/shadcn-templ/v2/components/drawer") templ DrawerSwipeHandle() { @drawer.Drawer(drawer.Props{ShowSwipeHandle: true}) { @button.Button(button.Props{Variant: button.VariantSecondary, Attributes: drawer.Trigger(ctx)}) { Open Drawer } @drawer.Content() { @drawer.Header() { @drawer.Title() { Drawer } @drawer.Description() { Drawer with a swipe handle. } } <div class="flex-1 p-4"> <div class="rounded-2xl bg-muted group-data-[swipe-axis=x]/drawer-popup:size-full group-data-[swipe-axis=y]/drawer-popup:h-80 group-data-[swipe-axis=y]/drawer-popup:w-full"></div> </div> @drawer.Footer() { @button.Button(button.Props{Attributes: drawer.Close(ctx)}) { Close } } } }}Nested
Open drawers from inside another drawer. Parent drawers stay mounted and stack behind the frontmost drawer.
package examples import (package examples import ( "github.com/axadrn/shadcn-templ/v2/components/button" "github.com/axadrn/shadcn-templ/v2/components/drawer") templ DrawerNested() { @drawer.Drawer(drawer.Props{ID: "drawer-nested-1", SwipeDirection: drawer.SwipeDirectionRight, ShowSwipeHandle: true}) { @button.Button(button.Props{Variant: button.VariantSecondary, Attributes: drawer.Trigger(ctx)}) { Open Drawer } @drawer.Content() { @drawer.Header() { @drawer.Title() { Drawer } @drawer.Description() { Open another drawer from the same direction. } } <div class="flex-1 p-4"> <div class="bg-muted group-data-[swipe-axis=x]/drawer-popup:size-full group-data-[swipe-axis=y]/drawer-popup:aspect-video group-data-[swipe-axis=y]/drawer-popup:w-full"></div> </div> @drawer.Footer() { @drawer.Drawer(drawer.Props{ID: "drawer-nested-2", SwipeDirection: drawer.SwipeDirectionRight, ShowSwipeHandle: true}) { @button.Button(button.Props{Variant: button.VariantOutline, Attributes: drawer.Trigger(ctx)}) { Open Nested Drawer } @drawer.Content() { @drawer.Header() { @drawer.Title() { Nested Drawer } @drawer.Description() { The parent drawer stays mounted behind this one. } } <div class="flex-1 p-4"> <div class="bg-muted group-data-[swipe-axis=x]/drawer-popup:size-full group-data-[swipe-axis=y]/drawer-popup:aspect-video group-data-[swipe-axis=y]/drawer-popup:w-full"></div> </div> @drawer.Footer() { @drawer.Drawer(drawer.Props{ID: "drawer-nested-3", SwipeDirection: drawer.SwipeDirectionRight, ShowSwipeHandle: true}) { @button.Button(button.Props{Variant: button.VariantOutline, Attributes: drawer.Trigger(ctx)}) { Open Third Drawer } @drawer.Content() { @drawer.Header() { @drawer.Title() { Third Drawer } @drawer.Description() { Two drawers are stacked behind this one. } } <div class="flex-1 p-4"> <div class="bg-muted group-data-[swipe-axis=x]/drawer-popup:size-full group-data-[swipe-axis=y]/drawer-popup:aspect-video group-data-[swipe-axis=y]/drawer-popup:w-full"></div> </div> @drawer.Footer() { @drawer.Drawer(drawer.Props{ID: "drawer-nested-4", SwipeDirection: drawer.SwipeDirectionRight, ShowSwipeHandle: true}) { @button.Button(button.Props{Variant: button.VariantOutline, Attributes: drawer.Trigger(ctx)}) { Open Fourth Drawer } @drawer.Content() { @drawer.Header() { @drawer.Title() { Fourth Drawer } @drawer.Description() { This is the frontmost drawer in the stack. } } <div class="flex-1 p-4"> <div class="bg-muted group-data-[swipe-axis=x]/drawer-popup:size-full group-data-[swipe-axis=y]/drawer-popup:aspect-video group-data-[swipe-axis=y]/drawer-popup:w-full"></div> </div> @drawer.Footer() { @button.Button(button.Props{Variant: button.VariantOutline, Attributes: drawer.Close(ctx)}) { Close } } } } @button.Button(button.Props{Variant: button.VariantOutline, Attributes: drawer.Close(ctx)}) { Close } } } } @button.Button(button.Props{Variant: button.VariantOutline, Attributes: drawer.Close(ctx)}) { Close } } } } @button.Button(button.Props{Variant: button.VariantOutline, Attributes: drawer.Close(ctx)}) { Close } } } } <script nonce={ templ.GetNonce(ctx) }> (() => { // useIsMobile pendant (hooks/use-mobile.ts), like the drawer-demo: // swipeSwipeDirection is down and the swipe handle shows below the md // breakpoint, right and no handle from md up. The dialogs wait in // nested portal <template>s until drawer.js lifts them, so the // lookup searches template contents recursively; attributes set // on the nodes survive the lift (same nodes). const findDialog = (root, id) => { const direct = root.getElementById?.(id) || root.querySelector?.("#" + CSS.escape(id)); if (direct) return direct; for (const tpl of root.querySelectorAll("template[data-tui-drawer-portal]")) { const found = findDialog(tpl.content, id); if (found) return found; } return null; }; const ids = ["drawer-nested-1", "drawer-nested-2", "drawer-nested-3", "drawer-nested-4"]; const mq = window.matchMedia("(max-width: 767px)"); const apply = () => { for (const id of ids) { const dialog = findDialog(document, id); const popup = dialog?.querySelector('[data-tui-drawer-popup]'); if (!popup) continue; popup.setAttribute("data-swipe-direction", mq.matches ? "down" : "right"); popup.setAttribute("data-swipe-axis", mq.matches ? "y" : "x"); const handle = popup.querySelector('[data-slot="drawer-swipe-handle"]'); // Inline display, because the handle's flex class would // win over the [hidden] UA rule. if (handle) handle.style.display = mq.matches ? "" : "none"; } }; apply(); mq.addEventListener("change", apply); })(); </script>}Non Modal
Set DisableModal to allow interaction with the rest of the page while the drawer is open. Combine with DisableDismissible to prevent the drawer from closing on outside presses.
package examples import (package examples import ( "github.com/axadrn/shadcn-templ/v2/components/button" "github.com/axadrn/shadcn-templ/v2/components/drawer") templ DrawerNonModal() { @drawer.Drawer(drawer.Props{DisableModal: true, DisableDismissible: true, SwipeDirection: drawer.SwipeDirectionRight}) { @button.Button(button.Props{Variant: button.VariantOutline, Attributes: drawer.Trigger(ctx)}) { Non Modal } @drawer.Content() { @drawer.Header() { @drawer.Title() { Non Modal Drawer } } <div class="flex-1 p-4"> <div class="rounded-2xl bg-muted group-data-[swipe-axis=x]/drawer-popup:size-full group-data-[swipe-axis=y]/drawer-popup:h-80 group-data-[swipe-axis=y]/drawer-popup:w-full"></div> </div> @drawer.Footer() { @button.Button(button.Props{Attributes: drawer.Close(ctx)}) { Close } } } }}Snap Points
Use SnapPoints to snap a drawer to preset heights. Numbers between 0 and 1 represent fractions of the viewport. Numbers greater than 1 are treated as pixel values. String values support px and rem units. Snap points apply to vertical drawers.
Track and control the active snap point with window.tui.drawer.getSnapPoint(id) and window.tui.drawer.setSnapPoint(id, value). At the full snap point, the drawer gets a data-expanded attribute you can style with the data-expanded: variant.
package examples import (package examples import ( "github.com/axadrn/shadcn-templ/v2/components/button" "github.com/axadrn/shadcn-templ/v2/components/drawer") templ DrawerSnapPoints() { @drawer.Drawer(drawer.Props{SnapPoints: []any{"31rem", 1}, ShowSwipeHandle: true}) { @button.Button(button.Props{Variant: button.VariantOutline, Attributes: drawer.Trigger(ctx)}) { Open Snap Drawer } @drawer.Content() { @drawer.Header() { @drawer.Title() { Snap points } @drawer.Description() { Drag the drawer to snap between a compact peek and a near full-height view. } } <div class="flex-1 p-4"> <div class="rounded-2xl bg-muted group-data-[swipe-axis=x]/drawer-popup:size-full group-data-[swipe-axis=y]/drawer-popup:h-80 group-data-[swipe-axis=y]/drawer-popup:w-full"></div> </div> @drawer.Footer() { @button.Button(button.Props{Attributes: drawer.Close(ctx)}) { Close } } } }}Responsive
You can combine the Dialog and Drawer components to create a responsive dialog. This renders a Dialog component on desktop and a Drawer on mobile.
package examples import (package examples import ( "github.com/axadrn/shadcn-templ/v2/components/button" "github.com/axadrn/shadcn-templ/v2/components/dialog" "github.com/axadrn/shadcn-templ/v2/components/drawer" "github.com/axadrn/shadcn-templ/v2/components/input" "github.com/axadrn/shadcn-templ/v2/components/label" "github.com/axadrn/shadcn-templ/v2/utils") // DrawerDialog is the responsive dialog: a Dialog on desktop and a Drawer on// mobile (the reference's useMediaQuery("(min-width: 768px)")). Both are// SSR'd; the demo-local script below shows one and hides the other.templ DrawerDialog() { <div data-tui-drawer-dialog-desktop hidden> @dialog.Dialog(dialog.Props{ID: "drawer-dialog-desktop"}) { @button.Button(button.Props{Variant: button.VariantOutline, Attributes: dialog.Trigger(ctx)}) { Edit Profile } @dialog.Content(dialog.ContentProps{Class: "sm:max-w-[425px]"}) { @dialog.Header() { @dialog.Title() { Edit profile } @dialog.Description() { Make changes to your profile here. Click save when you're done. } } @drawerDialogProfileForm("") } } </div> <div data-tui-drawer-dialog-mobile hidden> @drawer.Drawer(drawer.Props{ID: "drawer-dialog-mobile"}) { @button.Button(button.Props{Variant: button.VariantOutline, Attributes: drawer.Trigger(ctx)}) { Edit Profile } @drawer.Content() { @drawer.Header(drawer.HeaderProps{Class: "text-left"}) { @drawer.Title() { Edit profile } @drawer.Description() { Make changes to your profile here. Click save when you're done. } } @drawerDialogProfileForm("p-4") } } </div> <script nonce={ templ.GetNonce(ctx) }> (() => { // useMediaQuery pendant: show the Dialog markup on desktop and // the Drawer on mobile. Both are server-rendered; only one set of // triggers is visible at a time. Demo-local behavior, not a // component API. const mq = window.matchMedia("(min-width: 768px)"); const apply = () => { document.querySelectorAll("[data-tui-drawer-dialog-desktop]").forEach((el) => { el.hidden = !mq.matches; }); document.querySelectorAll("[data-tui-drawer-dialog-mobile]").forEach((el) => { el.hidden = mq.matches; }); }; apply(); mq.addEventListener("change", apply); })(); </script>} templ drawerDialogProfileForm(class string) { <form class={ utils.CN("grid items-start gap-6", class) }> <div class="grid gap-3"> @label.Label(label.Props{For: "drawer-dialog-email"}) { Email } @input.Input(input.Props{ID: "drawer-dialog-email", Type: "email", Value: "[email protected]"}) </div> <div class="grid gap-3"> @label.Label(label.Props{For: "drawer-dialog-username"}) { Username } @input.Input(input.Props{ID: "drawer-dialog-username", Value: "@axadrn"}) </div> @button.Button(button.Props{Type: button.TypeSubmit}) { Save changes } </form>}API Reference
Drawer
The drawer.Drawer component is the root, it carries the id and options that link trigger and content.
DrawerTrigger
drawer.Trigger(ctx) returns the attributes that turn any element into the drawer trigger, drawer.TriggerFor(id) targets a drawer outside the current root. drawer.Close(ctx) and drawer.CloseFor(id) close it.
DrawerContent
The drawer.Content component is the sliding panel.
DrawerSwipeHandle
The drawer.SwipeHandle component is the drag handle bar. drawer.Content renders it automatically when ShowSwipeHandle is set on the root.
DrawerHeader
The drawer.Header component wraps the title and description.
DrawerFooter
The drawer.Footer component holds actions at the bottom.
DrawerTitle
The drawer.Title component renders the accessible drawer title.
DrawerDescription
The drawer.Description component renders the accessible drawer description.