Wuko
Components

Modal

An accessible dialog with focus trap, ESC-to-close, and overlay click handling. Built on Radix Dialog primitives. Controlled via open / onClose; supports title, optional description, footer slot, and children body.

Installation

terminal
npx shadcn@latest add @wuko/modal

Usage

Modal is fully controlled. Hold the open state in your component, render the trigger separately, and pass open and onClose down. Radix handles focus trap, ESC, click-outside, and ARIA wiring. Your code just decides when the dialog is visible.

components/delete-project-dialog.tsx
"use client";

import { useState } from "react";

import { Button } from "@/components/ui/button";
import { Modal } from "@/components/ui/modal";

export function DeleteProjectDialog() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <Button onClick={() => setOpen(true)}>Open dialog</Button>
      <Modal
        open={open}
        onClose={() => setOpen(false)}
        title="Delete project?"
        description="This will permanently delete wuko-app and all of its data. This action cannot be undone."
        footer={
          <>
            <Button variant="ghost" onClick={() => setOpen(false)}>
              Cancel
            </Button>
            <Button variant="danger" onClick={() => setOpen(false)}>
              Delete
            </Button>
          </>
        }
      />
    </>
  );
}

Props

PropTypeDefaultDescription
openbooleanRequired. Whether the dialog is visible. Modal is fully controlled. Manage this state in your component.
onClose() => voidCalled when the user dismisses the dialog (close button, ESC, or backdrop click). Without this, the modal cannot be dismissed.
titlestringRequired. Header title. Wired to aria-labelledby on the dialog content.
descriptionstringOptional descriptive text rendered below the title. When passed, wired to aria-describedby. Strongly recommended for confirmations and destructive actions.
footerReactNodeFooter region, typically action buttons. Right-aligned with a top divider.
childrenReactNodeBody content. Renders below the description (if any) and above the footer.
classNamestringTailwind classes merged onto the dialog content (the panel). Does not target the overlay.

Accessibility

  • Focus trap.While open, keyboard focus is trapped inside the dialog. Tab and Shift-Tab cycle through the dialog's focusable elements only. Implemented by Radix's FocusScope primitive.
  • Auto-focus on open. Focus moves to the first focusable element inside the dialog when it opens. On close, focus returns to the element that triggered the open (typically the open button).
  • ESC and click-outside close. Pressing Escape or clicking the backdrop both dismiss the dialog. Both fire onClose.
  • Body scroll lock. While the dialog is open, the page behind the backdrop cannot be scrolled. Restored on close.
  • ARIA wiring. The dialog has role="dialog" and aria-modal="true". The title prop is wired as aria-labelledby. When you pass description, it is wired as aria-describedby; when you omit it, aria-describedby is set to undefined explicitly to suppress Radix's dev-mode warning.
  • When to pass description. Pass it for confirmations, destructive actions, and any dialog where a screen-reader user needs context beyond the title. For trivial dialogs (e.g., a brief acknowledgement), title + children is acceptable.
  • Close button. The X close button has aria-label="Close". The X svg is aria-hidden; screen readers announce only the label.
  • Consumer responsibility. You render the trigger (typically a Button) and manage openstate. Modal handles everything inside the dialog (focus, ARIA, dismiss, scroll lock) so the consumer doesn't need to wire those up.