ComponentsThemes
Palette
Default
Stone
Rose
Blue
Green
Violet
Yellow
Orange
6

← Components

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/vanilla

Drawer 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)

Left

Top

Bottom

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
# 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.