Drawer
Side-anchored dialog (top/right/bottom/left). Shares the dialog state machine and controller.
Installation
bin/rails g wabi:add drawer
bin/importmap pin @zag-js/dialog
bin/importmap pin @zag-js/vanillaDrawer reuses Zag's dialog machine — pin @zag-js/dialog (not @zag-js/drawer) and @zag-js/vanilla at version 1.41+ using the +esm jsdelivr URLs; bin/importmap pin only downloads the main entry and leaves submodule imports unresolved.
Example
Right (default)
Settings
Adjust your preferences below.
Settings panel content goes here.
render Components::UI::Drawer.new(side: :right) do
render Components::UI::DrawerTrigger.new(
class: "inline-flex items-center justify-center rounded-md text-sm font-medium " "bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2"
) { "Open (right)" }
render Components::UI::DrawerContent.new(side: :right) do
render Components::UI::DrawerHeader.new do
render Components::UI::DrawerTitle.new { "Settings" }
render Components::UI::DrawerDescription.new { "Adjust your preferences below." }
end
p(class: "text-sm text-muted-foreground py-4") { "Settings panel content goes here." }
render Components::UI::DrawerFooter.new do
render Components::UI::DrawerClose.new { "Close" }
end
end
end
Left
Navigation
Browse sections.
Nav links go here.
render Components::UI::Drawer.new(side: :left) do
render Components::UI::DrawerTrigger.new(
class: "inline-flex items-center justify-center rounded-md text-sm font-medium " "border border-input bg-background hover:bg-accent hover:text-accent-foreground " "h-10 px-4 py-2"
) { "Open (left)" }
render Components::UI::DrawerContent.new(side: :left) do
render Components::UI::DrawerHeader.new do
render Components::UI::DrawerTitle.new { "Navigation" }
render Components::UI::DrawerDescription.new { "Browse sections." }
end
p(class: "text-sm text-muted-foreground py-4") { "Nav links go here." }
render Components::UI::DrawerFooter.new do
render Components::UI::DrawerClose.new { "Close" }
end
end
end
Top
Notifications
Your recent alerts.
Notification list goes here.
render Components::UI::Drawer.new(side: :top) do
render Components::UI::DrawerTrigger.new(
class: "inline-flex items-center justify-center rounded-md text-sm font-medium " "border border-input bg-background hover:bg-accent hover:text-accent-foreground " "h-10 px-4 py-2"
) { "Open (top)" }
render Components::UI::DrawerContent.new(side: :top) do
render Components::UI::DrawerHeader.new do
render Components::UI::DrawerTitle.new { "Notifications" }
render Components::UI::DrawerDescription.new { "Your recent alerts." }
end
p(class: "text-sm text-muted-foreground py-4") { "Notification list goes here." }
render Components::UI::DrawerFooter.new do
render Components::UI::DrawerClose.new { "Dismiss" }
end
end
end
Bottom
Share
Send this item to a teammate.
Share options go here.
render Components::UI::Drawer.new(side: :bottom) do
render Components::UI::DrawerTrigger.new(
class: "inline-flex items-center justify-center rounded-md text-sm font-medium " "border border-input bg-background hover:bg-accent hover:text-accent-foreground " "h-10 px-4 py-2"
) { "Open (bottom)" }
render Components::UI::DrawerContent.new(side: :bottom) do
render Components::UI::DrawerHeader.new do
render Components::UI::DrawerTitle.new { "Share" }
render Components::UI::DrawerDescription.new { "Send this item to a teammate." }
end
p(class: "text-sm text-muted-foreground py-4") { "Share options go here." }
render Components::UI::DrawerFooter.new do
render Components::UI::DrawerClose.new { "Cancel" }
end
end
end
Source
app/components/ui/drawer.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class Drawer < Wabi::Base
def initialize(id: nil, open: false, side: :right, portal: true, **attrs)
@id = id
@open = open
@side = side
@portal = portal
@attrs = attrs
end
def view_template(&block)
div(
id: @id,
data: {
controller: "wabi--dialog",
"wabi--dialog-open-value": @open.to_s,
"wabi--dialog-modal-value": "true",
"wabi--dialog-portal-value": @portal.to_s,
"wabi-side": @side.to_s,
}
) do
yield if block_given?
end
end
end
end
end
app/components/ui/drawer_trigger.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class DrawerTrigger < Wabi::Base
# @param aria_label [String, nil] Accessible label forwarded as aria-label
# on the underlying button. Required when the trigger slot contains only
# an icon (e.g. a hamburger SVG with no visible text); omit when the slot
# contains descriptive text.
# Example:
# DrawerTrigger.new(aria_label: "Open navigation menu") { hamburger_icon }
def initialize(aria_label: nil, **attrs)
@aria_label = aria_label
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
button(
type: "button",
aria_label: @aria_label,
data: { "wabi--dialog-target": "trigger" },
class: user_class,
**@attrs
) do
yield if block_given?
end
end
end
end
end
app/components/ui/drawer_content.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class DrawerContent < Wabi::Base
# Side-anchored positioning + slide-in/out transform per side. Each side
# has its own "off-screen" translate when closed; opening returns to
# `translate-0`. v0.1.x: visibility lives on `data-state` rather than
# `hidden`, so the slide actually animates.
SIDE_CLASSES = {
top: "fixed inset-x-0 top-0 w-full max-h-screen border border-input " \
"data-[state=open]:translate-y-0 data-[state=closed]:-translate-y-full",
right: "fixed inset-y-0 right-0 h-full w-3/4 sm:max-w-sm border border-input " \
"data-[state=open]:translate-x-0 data-[state=closed]:translate-x-full",
bottom: "fixed inset-x-0 bottom-0 w-full max-h-screen border border-input " \
"data-[state=open]:translate-y-0 data-[state=closed]:translate-y-full",
left: "fixed inset-y-0 left-0 h-full w-3/4 sm:max-w-sm border border-input " \
"data-[state=open]:translate-x-0 data-[state=closed]:-translate-x-full",
}.freeze
# `data-[state=closed]:pointer-events-none` keeps the off-screen drawer
# from intercepting clicks at its side strip when closed (e.g. a right
# drawer translated 100% off-screen still has its bounding box at the
# right edge until pointer-events-none is applied).
BASE = "z-50 grid gap-4 bg-background p-6 shadow-lg " \
"transition-transform duration-300 ease-out motion-reduce:transition-none " \
"motion-reduce:translate-x-0 motion-reduce:translate-y-0 " \
"data-[state=open]:pointer-events-auto data-[state=closed]:pointer-events-none"
BACKDROP_CLASS = "fixed inset-0 z-40 bg-black/80 " \
"transition-opacity duration-200 ease-out motion-reduce:transition-none " \
"data-[state=open]:opacity-100 data-[state=open]:pointer-events-auto " \
"data-[state=closed]:opacity-0 data-[state=closed]:pointer-events-none"
def initialize(side: :right, **attrs)
@side = side
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
div(
data: { "wabi--dialog-target": "backdrop" },
"data-state": "closed",
class: BACKDROP_CLASS
)
div(
role: "dialog",
"aria-modal": "true",
"data-state": "closed",
data: { "wabi--dialog-target": "content" },
inert: true,
class: merge_class(BASE, SIDE_CLASSES.fetch(@side), user_class)
) do
yield if block_given?
end
end
end
end
end
app/components/ui/drawer_header.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class DrawerHeader < Wabi::Base
variants { base "grid gap-1.5 text-center sm:text-left" }
def initialize(**attrs)
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
div(class: merge_class(tokens, user_class)) do
yield if block_given?
end
end
end
end
end
app/components/ui/drawer_footer.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class DrawerFooter < Wabi::Base
variants { base "mt-auto flex flex-col gap-2" }
def initialize(**attrs)
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
div(class: merge_class(tokens, user_class)) do
yield if block_given?
end
end
end
end
end
app/components/ui/drawer_title.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class DrawerTitle < Wabi::Base
variants { base "text-lg font-semibold leading-none tracking-tight" }
def initialize(**attrs)
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
h2(
data: { "wabi--dialog-target": "title" },
class: merge_class(tokens, user_class)
) do
yield if block_given?
end
end
end
end
end
app/components/ui/drawer_description.rb
# frozen_string_literal: true
require "date"
module Components
module UI
class DrawerDescription < Wabi::Base
variants { base "text-sm text-muted-foreground" }
def initialize(**attrs)
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
p(
data: { "wabi--dialog-target": "description" },
class: merge_class(tokens, user_class)
) do
yield if block_given?
end
end
end
end
end
app/components/ui/drawer_close.rb
# frozen_string_literal: true
require "date"
module Components
module UI
# Outline button tagged as the Zag closeTrigger -- click auto-closes the
# drawer via the same `wabi--dialog` controller (no manual data-action wiring
# needed). Matches the DialogCancel pattern.
#
# WCAG AA (4.1.2 / 1.1.1): When the slot contains only an icon (e.g. an X
# SVG with no visible text) the button would have no accessible name.
# Pass `aria_label:` to override the default "Close" label.
# Example:
# DrawerClose.new(aria_label: "Close settings drawer") { x_icon }
class DrawerClose < Wabi::Base
# @param aria_label [String, nil] Accessible label forwarded as aria-label
# on the underlying button. Defaults to "Close" so icon-only close
# buttons are always named; callers may override with a more specific
# description (e.g. "Close settings drawer").
def initialize(aria_label: nil, **attrs)
@aria_label = aria_label
@attrs = attrs
end
def view_template(&block)
# Merge caller-supplied aria: hash (if any) with our default label so
# neither side silently wins.
caller_aria = @attrs.delete(:aria) || {}
resolved_aria = { label: @aria_label || "Close" }.merge(caller_aria)
render Components::UI::Button.new(
appearance: :outline,
aria: resolved_aria,
data: { "wabi--dialog-target": "closeTrigger" },
**@attrs
) do
yield if block_given?
end
end
end
end
end
Accessibility
- Drawer uses Zag's dialog machine — focus is trapped inside while open, scroll lock applies to the body.
- Escape closes; clicking the backdrop closes; focus returns to the trigger on close.
- The side: prop is purely visual — semantics remain role="dialog" + aria-modal="true" regardless of slide direction.
- DrawerTitle and DrawerDescription wire aria-labelledby/aria-describedby on the dialog panel.