ComponentsThemes
Palette
Default
Stone
Rose
Blue
Green
Violet
Yellow
Orange
6

← Components

Dialog

Modal dialog with focus trap, Escape dismiss, click-outside dismiss, and portal.

Installation

bin/rails g wabi:add dialog
bin/importmap pin @zag-js/dialog
bin/importmap pin @zag-js/vanilla

Pin @zag-js/dialog 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

Source

app/components/ui/dialog.rb

# frozen_string_literal: true

require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes

module Components
  module UI
    class Dialog < Wabi::Base
      def initialize(id: nil, open: false, modal: true, portal: true, **attrs)
        @id     = id
        @open   = open
        @modal  = modal
        @portal = portal
        @attrs  = attrs
      end

      def view_template(&block)
        div(
          id: @id,
          data: {
            controller: "wabi--dialog",
            # `.to_s` matters for Stimulus Boolean values -- a bare boolean true
            # serializes to a value-less attribute `data-...-value`, which
            # Stimulus then parses as the string "" and treats as `false`.
            # Emitting "true"/"false" strings makes the value type roundtrip.
            "wabi--dialog-open-value":   @open.to_s,
            "wabi--dialog-modal-value":  @modal.to_s,
            "wabi--dialog-portal-value": @portal.to_s,
          }
        ) do
          yield if block_given?
        end
      end
    end
  end
end

app/components/ui/dialog_trigger.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    class DialogTrigger < Wabi::Base
      def initialize(**attrs)
        @attrs = attrs
      end

      def view_template(&block)
        user_class = @attrs.delete(:class)
        button(
          type: "button",
          data: { "wabi--dialog-target": "trigger" },
          class: user_class
        ) do
          yield if block_given?
        end
      end
    end
  end
end

app/components/ui/dialog_content.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    class DialogContent < Wabi::Base
      variants do
        # `data-[state=closed]:pointer-events-none` is critical: when closed,
        # the content is `opacity-0` (invisible) but STILL `fixed` with a
        # centered footprint. Without disabling pointer events when closed,
        # the invisible box intercepts clicks in the center of the viewport.
        # Same trap as the original positioner-covers-everything bug.
        base "fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 " \
             "gap-4 border border-input bg-background p-6 shadow-lg sm:rounded-lg " \
             "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"
      end

      # Backdrop also needs the pointer-events flip -- it's `fixed inset-0`
      # which covers the entire viewport even at opacity 0.
      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"

      POSITIONER_CLASS = "fixed inset-0 z-50 flex items-center justify-center pointer-events-none"

      # alert: [Boolean] optional, default false.
      #   Pass `alert: true` for destructive or confirmation dialogs that require
      #   an immediate user response. Renders `role="alertdialog"` instead of
      #   `role="dialog"` per ARIA 1.2. The JS controller will re-apply the
      #   correct role after Zag's spreadProps (which always emits "dialog").
      def initialize(alert: false, **attrs)
        @alert = alert
        @attrs = attrs
      end

      def view_template(&block)
        user_class = @attrs.delete(:class)
        # Visibility now lives on `data-state` rather than the `hidden`
        # attribute. The controller force-clears `hidden` after spreadProps
        # (Zag still sets it from getContentProps/getBackdropProps) and
        # applies `inert` on the content when closed so tab order skips it.
        # Without that switch, `hidden` cascades display:none and CSS
        # transitions never run (the element snaps off-screen mid-fade).
        div(
          data: { "wabi--dialog-target": "backdrop" },
          "data-state": "closed",
          class: BACKDROP_CLASS
        )
        div(
          data: { "wabi--dialog-target": "positioner" },
          class: POSITIONER_CLASS
        ) do
          div(
            role: (@alert ? "alertdialog" : "dialog"),
            "aria-modal": "true",
            "data-state": "closed",
            data: {
              "wabi--dialog-target": "content",
              # Signal the controller to re-apply role="alertdialog" after
              # Zag's spreadProps overwrites it with its hardcoded "dialog".
              "wabi--dialog-alert": @alert.to_s
            },
            inert: true,
            class: merge_class(tokens, user_class)
          ) do
            yield if block_given?
          end
        end
      end
    end
  end
end

app/components/ui/dialog_header.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    class DialogHeader < Wabi::Base
      variants { base "flex flex-col space-y-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 DialogFooter < Wabi::Base
      variants { base "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-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/dialog_title.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    class DialogTitle < 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/dialog_description.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    class DialogDescription < 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/dialog_action.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    # Primary "Confirm" button. Does NOT auto-close -- the caller wires
    # `data-action="click->wabi--dialog#close"` (or their own handler) so they
    # can persist before dismissing.
    class DialogAction < Wabi::Base
      def initialize(**attrs)
        @attrs = attrs
      end

      def view_template(&block)
        render Components::UI::Button.new(**@attrs) do
          yield if block_given?
        end
      end
    end
  end
end

app/components/ui/dialog_cancel.rb

# frozen_string_literal: true

require "date"

module Components
  module UI
    # Outlined "Cancel" button. Tagged as a Zag closeTrigger so the dialog
    # closes on click without the caller needing to wire data-action manually.
    class DialogCancel < Wabi::Base
      def initialize(**attrs)
        @attrs = attrs
      end

      def view_template(&block)
        render Components::UI::Button.new(
          appearance: :outline,
          data: { "wabi--dialog-target": "closeTrigger" },
          **@attrs
        ) do
          yield if block_given?
        end
      end
    end
  end
end

Accessibility

  • role="dialog" + aria-modal="true"; title/description wired via aria-labelledby/aria-describedby.
  • Initial focus moves into the dialog on open; restored to trigger on close.
  • Focus trap keeps Tab inside the dialog while open.
  • Backdrop click and Escape close the dialog (configurable via Zag opts).
  • Content carries inert when closed — keeps out of tab order + a11y tree (Zag onOpenChange synchronous toggle).
  • Scroll lock applied to <body> while modal is open.