Skip to Content
Hashti Design System
Components

Modal

Dialog overlay that temporarily blocks page interaction to present urgent content or complex sub-flows.

Modal

The Modal component creates a focused overlay container displayed above the main application interface. By dimming and blurring the background, it establishes an exclusive spatial threshold—demanding the user’s attention for critical decisions, forms, or detailed sub-flows before returning to the underlying page.

In Hashti’s spatial vocabulary, a Modal functions much like entering an interior pavilion (kushk): outside distractions fade into a softened backdrop, while internal content is rendered with utmost clarity and geometric balance.

Live Interactive Modal DialogOpen the modal to test backdrop blur, escape key dismissal, and focus trapping.

Import

import { Modal, Dialog, DialogTrigger, Button, Heading } from "@hashti/react";

Examples

Standard Modal with DialogTrigger

Compose <DialogTrigger> with a trigger <Button>, a <Modal>, and a nested <Dialog>:

import { Modal, Dialog, DialogTrigger, Button, Heading } from "@hashti/react";

export function BasicModalDemo() {
  return (
    <DialogTrigger>
      <Button variant="primary">New Restoration Project</Button>
      <Modal>
        <Dialog>
          {({ close }) => (
            <div className="flex flex-col gap-4 p-6">
              <Heading slot="title" className="text-xl font-bold tracking-tight">
                Create Restoration Workspace
              </Heading>
              <p className="text-sm text-gray-600 dark:text-gray-400">
                Configure your structural conservation initiative. You can invite team members and
                link CAD models immediately after setup.
              </p>
              <div className="flex justify-end gap-3 pt-4 border-t border-gray-200 dark:border-gray-800">
                <Button variant="ghost" onPress={close}>
                  Cancel
                </Button>
                <Button variant="primary" onPress={close}>
                  Create Workspace
                </Button>
              </div>
            </div>
          )}
        </Dialog>
      </Modal>
    </DialogTrigger>
  );
}

Controlled Modal State

Control modal visibility externally using isOpen and onOpenChange:

import { useState } from "react";
import { Modal, Dialog, Button, Heading, TextField } from "@hashti/react";

export function ControlledModalDemo() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <>
      <Button variant="secondary" onPress={() => setIsOpen(true)}>
        Open Settings Modal
      </Button>

      <Modal isOpen={isOpen} onOpenChange={setIsOpen}>
        <Dialog>
          <div className="flex flex-col gap-4 p-6">
            <Heading slot="title" className="text-lg font-bold">
              Account Preferences
            </Heading>
            <TextField label="Display Name" defaultValue="Parisa Rahimi" />
            <TextField label="Contact Email" defaultValue="[email protected]" />
            <div className="flex justify-end gap-3 pt-2">
              <Button variant="ghost" onPress={() => setIsOpen(false)}>
                Cancel
              </Button>
              <Button variant="primary" onPress={() => setIsOpen(false)}>
                Save Changes
              </Button>
            </div>
          </div>
        </Dialog>
      </Modal>
    </>
  );
}

Modals are available in four proportional size tiers: xs (384px), sm (448px), md (576px, default), and lg (768px):

import { useState } from "react";
import { Modal, Dialog, Button, Heading } from "@hashti/react";

type ModalSize = "xs" | "sm" | "md" | "lg";

export function ModalSizesDemo() {
  const [activeSize, setActiveSize] = useState<ModalSize | null>(null);

  return (
    <div className="flex flex-wrap gap-3">
      {(["xs", "sm", "md", "lg"] as ModalSize[]).map((size) => (
        <Button key={size} variant="secondary" onPress={() => setActiveSize(size)}>
          Open Size: {size.toUpperCase()}
        </Button>
      ))}

      {activeSize && (
        <Modal
          size={activeSize}
          isOpen={activeSize !== null}
          onOpenChange={(open) => !open && setActiveSize(null)}
        >
          <Dialog>
            {({ close }) => (
              <div className="p-6 flex flex-col gap-3">
                <Heading slot="title" className="text-lg font-bold">
                  Modal Size: {activeSize.toUpperCase()}
                </Heading>
                <p className="text-sm text-gray-600 dark:text-gray-400">
                  This modal container uses the <code>{activeSize}</code> size constraint.
                </p>
                <div className="flex justify-end pt-4">
                  <Button variant="primary" onPress={close}>
                    Dismiss
                  </Button>
                </div>
              </div>
            )}
          </Dialog>
        </Modal>
      )}
    </div>
  );
}

Non-Dismissable Modal

For mandatory, transactional flows (such as accepting terms or signing contracts), set isDismissable={false} to prevent dismissal when clicking the backdrop or pressing Esc:

import { useState } from "react";
import { Modal, Dialog, Button, Heading } from "@hashti/react";

export function NonDismissableModalDemo() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <>
      <Button variant="danger" onPress={() => setIsOpen(true)}>
        Review Licensing Terms
      </Button>

      <Modal isOpen={isOpen} onOpenChange={setIsOpen} isDismissable={false}>
        <Dialog>
          {({ close }) => (
            <div className="p-6 flex flex-col gap-4">
              <Heading slot="title" className="text-lg font-bold">
                Mandatory Agreement
              </Heading>
              <p className="text-sm text-gray-600 dark:text-gray-400">
                You must explicitly accept or decline these heritage preservation terms. Clicking
                outside this window or pressing Escape will not dismiss it.
              </p>
              <div className="flex justify-end gap-3 pt-4">
                <Button variant="ghost" onPress={close}>
                  Decline
                </Button>
                <Button variant="primary" onPress={close}>
                  I Agree & Continue
                </Button>
              </div>
            </div>
          )}
        </Dialog>
      </Modal>
    </>
  );
}

Props Reference

PropTypeDefaultDescription
isOpenboolean—Controlled open visibility state.
defaultOpenbooleanfalseDefault open state for uncontrolled usage.
onOpenChange(isOpen: boolean) => void—Callback fired whenever the modal requests open/close.
size'xs' | 'sm' | 'md' | 'lg''md'Width and max-width dimension variant of the inner modal.
isDismissablebooleantrueAllows closing by clicking on the darkened backdrop or pressing Esc.
isKeyboardDismissDisabledbooleanfalseDisables closing via the Esc key specifically.
overlayClassNamestring—Custom CSS classes applied to the backdrop overlay.
modalStyleCSSProperties—Inline style applied directly to the inner modal surface.
childrenReactNode—Children to render, typically a <Dialog> element.

Accessibility

Modal strictly implements the WAI-ARIA Dialog (Modal) Pattern:

  • Focus Trap: Keyboard navigation (Tab / Shift + Tab) is strictly constrained within the modal while open. Focus cannot leak into the background page.
  • Focus Restoration: When the modal closes, keyboard focus automatically returns to the triggering element that invoked it.
  • Body Scroll Locking: Document background scrolling is locked while the modal is open, preventing disorienting background scroll behavior.
  • Inert Background: Surrounding page content outside the modal container is automatically marked with the HTML inert attribute and aria-hidden="true", ensuring assistive technologies can only inspect modal contents.
  • Accessible Labelling: When using <Heading slot="title">, the modal dialog is automatically linked via aria-labelledby.

Keyboard Interactions

KeyAction
TabCycles focus forward through tabbable elements trapped inside modal.
Shift+TabCycles focus backward within modal dialog trap.
EscDismisses modal dialog and restores focus to open trigger.

WAI-ARIA & Data Attributes

AttributeTargetDescription
[data-entering]Modal OverlayApplied during entrance opacity transition.
[data-exiting]Modal OverlayApplied during dismissal exit transition.