Components

Dialog

A modal dialog system with programmatic control, dialog stacking, and a centralized registry via useDialog and DialogManager.

Overview

The dialog system has two layers:

  1. Declarative (Radix-based): Dialog, DialogTrigger, DialogContent — render a dialog inline with a trigger button.
  2. Programmatic (useDialog + DialogManager): Open any registered dialog from anywhere in your app by calling openDialog('dialogType', props). Supports dialog stacking, dynamic props, and a centralised registry.

When to use

Use for actions that need explicit acknowledgment

Destructive confirmations (delete, discard, cancel subscription). Forms that gate a workflow (create audience, campaign settings). Alerts the user must read before continuing.

Use the programmatic API for cross-cutting dialogs

If a dialog can be opened from multiple places (e.g. a global "Upgrade plan" modal), register it with DialogManager and open via useDialog().openDialog('upgrade', props).

Don't use for non-critical feedback

Successful saves, transient errors, brief info → use Toaster. Persistent informational banners → use Alert.

Don't use for long multi-step flows

Anything more than ~2 steps belongs on a dedicated page or in a wizard. Modal stacking is supported but should be rare.

Don't use for quick selections

Picking from a short list of options → Dropdown or Popover. Filtering options on a page → keep them inline.

Choosing the right alternative

You want…Use this
Brief auto-dismissing feedback ("Saved!", "Failed to send")Toaster (toaster.success(...))
Persistent informational banner that stays on screenAlert
Destructive action requiring "yes" confirmationConfirmationDialog (a thin wrapper over Dialog)
Form taking over the screenDialog with a form inside
Side-anchored panel (filters, details)Drawer
Quick option pickerPopover or Dropdown
Tooltip on hover/focusTooltip (or HoverCard for richer content)

Installation

npm install @marmoui/ui
yarn add @marmoui/ui
pnpm add @marmoui/ui
bun add @marmoui/ui

Usage

// Declarative (inline trigger)
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogFooter, Button } from '@marmoui/ui';

// Programmatic (open from anywhere)
import { DialogProvider, DialogManager, useDialog } from '@marmoui/ui';

Declarative Dialogs

When to Use DialogHeader

DialogHeader renders a persistent title bar with a bottom border separator. It is not needed for every dialog — adding it to simple confirmations creates unnecessary visual weight. When you do use DialogHeader, usually keep it to DialogTitle only and put explanatory copy in the dialog body instead of adding DialogDescription to the header.

Use DialogHeader for forms and panels

Use it when the dialog has multiple fields, a sidebar, or contextual navigation. The title bar anchors the user and persists as content changes.

Use DialogHeader for lg and xl dialogs

Large dialogs benefit from a visible title bar as spatial orientation. The close button alone isn't sufficient at wide viewports.

Don't use DialogHeader for confirmations

Simple "Are you sure?" dialogs don't need a header. Put DialogTitle and DialogDescription directly in the content — the dialog is focused and temporary.

Don't pair header and description by default

If DialogHeader is present, usually omit DialogDescription. Put helper copy near the fields or in DialogContent body copy.

Don't use DialogHeader for destructive actions

Delete and discard confirmations follow the same rule. A header adds noise to an already urgent message.


Simple Confirmation

No DialogHeader. DialogTitle and DialogDescription go directly inside DialogContent. Best for quick confirmations, alerts, and single-purpose prompts.

Simple Confirmation

<Dialog>
			<DialogTrigger asChild>
				<Button variant="secondary" size="sm">
					Open Dialog
				</Button>
			</DialogTrigger>
			<DialogContent size="sm">
				<DialogTitle>Are you sure you want to perform this action?</DialogTitle>
				<DialogDescription>
					Please confirm your intention to proceed by clicking the "Confirm" button below.
				</DialogDescription>
				<DialogFooter>
					<Button variant="secondary">Cancel</Button>
					<Button variant="primary">Confirm</Button>
				</DialogFooter>
			</DialogContent>
		</Dialog>

Confirmation with Checkbox

<Dialog>
			<DialogTrigger asChild>
				<Button variant="secondary">Show Confirmation</Button>
			</DialogTrigger>
			<DialogContent>
				<DialogTitle>Are you sure you want to perform this action?</DialogTitle>
				<DialogDescription>
					Please confirm your intention to proceed by clicking the "Confirm" button below.
				</DialogDescription>
				<DialogFooter className="sm:justify-between sm:items-center">
					<label className="flex items-center gap-2 text-sm cursor-pointer select-none">
						<Checkbox />
						Don&apos;t show me this again
					</label>
					<div className="flex gap-2">
						<Button variant="secondary">Cancel</Button>
						<Button variant="primary">Confirm</Button>
					</div>
				</DialogFooter>
			</DialogContent>
		</Dialog>

Destructive Confirmation

<Dialog>
			<DialogTrigger asChild>
				<Button variant="destructive" size="sm">
					Delete Item
				</Button>
			</DialogTrigger>
			<DialogContent>
				<DialogTitle>Are you sure you want to perform this delete action?</DialogTitle>
				<DialogDescription>
					Please confirm your intention to proceed by clicking the "Delete" button below.
				</DialogDescription>
				<DialogFooter className="sm:justify-between sm:items-center">
					<label className="flex items-center gap-2 text-sm cursor-pointer select-none">
						<Checkbox />
						Don&apos;t show me this again
					</label>
					<div className="flex gap-2">
						<Button variant="secondary">Cancel</Button>
						<Button variant="destructive">Delete</Button>
					</div>
				</DialogFooter>
			</DialogContent>
		</Dialog>

Create / Edit Form

Uses DialogHeader because the form has multiple fields and users benefit from a persistent title. Avoid DialogDescription in the header; place helper copy in the body if needed.

Create / Edit Form

<Dialog>
			<DialogTrigger asChild>
				<Button variant="primary" size="sm">
					Create Audience
				</Button>
			</DialogTrigger>
			<DialogContent>
				<DialogHeader>
					<DialogTitle>Create Audience</DialogTitle>
				</DialogHeader>
				<div className="pt-4">
					<div className="mb-2">
						<label className="text-sm font-medium">
							Audience Name <span className="text-red-500">*</span>
						</label>
						<Input placeholder="e.g. All Subscribers" />
					</div>
					<div className="space-y-2">
						<label className="text-sm font-medium">Matching Lists</label>
						<Input placeholder="All Lists" />
					</div>
				</div>
				<DialogFooter>
					<Button variant="secondary">Cancel</Button>
					<Button variant="primary">Save</Button>
				</DialogFooter>
			</DialogContent>
		</Dialog>

Controlled Dialog

Control open/close state with React state. No header — this is a focused single-purpose dialog.

Controlled Dialog

function ControlledExample() {
const [open, setOpen] = useState(false);

return (
  <Dialog open={open} onOpenChange={setOpen}>
    <DialogTrigger asChild>
      <Button>Open Controlled</Button>
    </DialogTrigger>
    <DialogContent>
      <DialogTitle>Controlled Dialog</DialogTitle>
      <DialogDescription>
        This dialog state is controlled by React state.
      </DialogDescription>
      <DialogFooter>
        <Button variant="secondary" onClick={() => setOpen(false)}>
          Close
        </Button>
      </DialogFooter>
    </DialogContent>
  </Dialog>
);
}

Dialog Sizes

DialogContent accepts a size prop: sm (320px), md (620px, default), lg (980px), xl (1120px), or full (covers the entire viewport).

  • sm — quick confirmations, short prompts
  • md — most dialogs and forms (default)
  • lg — settings panels, complex forms, sidebars
  • xl — rich content, data tables, wide layouts
  • full — immersive flows, editors, mobile-first experiences

Size Variants

Click each button to see the different dialog widths

// Small — 320px
<DialogContent size="sm">...</DialogContent>

// Medium — 620px (default)
<DialogContent size="md">...</DialogContent>

// Large — 980px
<DialogContent size="lg">...</DialogContent>

// Extra Large — 1120px
<DialogContent size="xl">...</DialogContent>

// Full — covers entire viewport
<DialogContent size="full">...</DialogContent>

Scroll Patterns

By default dialogs render at their natural height centered in the viewport. When content is long, choose the scroll pattern that best fits the UX:

PatternWhen to use
Sticky footerLong forms or settings where the action buttons must always be visible
Scrollable contentBrowsable lists where no explicit confirmation is needed
Outside scrollLegal text, read-only documents, long articles where the dialog grows naturally

The scrollable prop on DialogContent applies max-h-[80vh] and grid-rows-[auto_1fr_auto] — the header/footer rows are auto-height and the 1fr middle row gives ScrollArea a concrete computed height so its internal viewport resolves correctly. Give ScrollArea className="min-h-0" so the grid can shrink it within the 1fr row.

Sticky Footer (Form)

Header and footer are pinned — a long input form scrolls in between

<DialogContent scrollable>
<DialogHeader>
  <DialogTitle>Create Contact</DialogTitle>
</DialogHeader>

<ScrollArea className="min-h-0">
  <div className="py-6 space-y-4 pr-2">
    <p className="text-sm text-ink-light">Fill in all details.</p>
    <Input placeholder="First Name" />
    <Input placeholder="Last Name" />
    {/* ...more fields... */}
  </div>
</ScrollArea>

{/* mt-0 removes default gap; border-t adds a visual separator */}
<DialogFooter className="mt-0 pt-6 border-t border-border-secondary">
  <Button variant="secondary">Cancel</Button>
  <Button variant="primary">Create Contact</Button>
</DialogFooter>
</DialogContent>

When a dialog contains a browsable list with no explicit confirmation step (selection happens via row click or the dialog is purely informational), omit DialogFooter. The ScrollArea expands to fill all remaining space below the header.

Scrollable Content

Header stays pinned — the list fills remaining space and scrolls

<DialogContent scrollable>
<DialogHeader>
  <DialogTitle>Choose a Template</DialogTitle>
</DialogHeader>

<ScrollArea className="min-h-0">
  <div className="py-2 pr-2">
    <p className="mb-3 text-sm text-ink-light">Select a starting point.</p>
    {templates.map((name, i) => (
      <div key={i} className="flex items-center gap-3 py-3 border-b last:border-0 cursor-pointer hover:bg-bg rounded">
        ...
      </div>
    ))}
  </div>
</ScrollArea>
</DialogContent>

Outside Scroll

Add the outsideScroll prop to DialogContent. The dialog renders without a max-height and grows naturally with its content. The dark backdrop becomes a scrollable container so users scroll the overlay rather than an internal area. Best for long read-only content like terms, changelogs, or documentation.

Outside Scroll

The dialog grows to fit its content — users scroll the backdrop overlay

// The dialog has no max-height; it expands naturally.
// The backdrop overlay becomes scrollable.
<DialogContent outsideScroll>
<DialogHeader>
  <DialogTitle>Terms &amp; Conditions</DialogTitle>
</DialogHeader>

<div className="pt-6 space-y-5">
  <p className="text-sm text-ink-light">Scroll down to read all sections.</p>
  {sections.map((s) => (
    <div key={s.title}>
      <h4 className="text-sm font-medium text-ink-dark mb-1">{s.title}</h4>
      <p className="text-sm text-ink-light">{s.body}</p>
    </div>
  ))}
</div>

<DialogFooter>
  <Button variant="secondary">Decline</Button>
  <Button variant="primary">Accept &amp; Continue</Button>
</DialogFooter>
</DialogContent>

Programmatic Dialogs with useDialog

For apps with many dialogs, the programmatic approach is cleaner. Instead of embedding Dialog + DialogTrigger everywhere, you register dialog components in a central config and open them from anywhere with openDialog().

Architecture

DialogProvider          ← Wraps your app, manages dialog stack
  ├── Your App
  │   └── useDialog()   ← Call openDialog('type', props) from any component
  └── DialogManager     ← Renders the active dialog from the stack
        └── config      ← Registry mapping type strings to components

Step 1: Create Dialog Components

Dialog components receive onClose and any props you pass to openDialog. They render the inner content only — DialogManager handles the outer Dialog and DialogContent wrapper.

Dialog Component Pattern

Each dialog is a standalone component that receives onClose and custom props


export function DeleteCampaignDialog({
dialogTitle,
campaignName,
onClose,
}: {
dialogTitle: string;
campaignName: string;
onClose: () => void;
}) {
return (
  <>
    <DialogTitle>{dialogTitle}</DialogTitle>
    <DialogDescription>
      {'Are you sure you want to delete ' + campaignName + '?'}
    </DialogDescription>
    <DialogFooter>
      <Button variant="secondary" onClick={onClose}>
        Cancel
      </Button>
      <Button variant="destructive" onClick={onClose}>
        Delete Campaign
      </Button>
    </DialogFooter>
  </>
);
}

Step 2: Create the Dialog Registry

Map string keys to dialog components. Use dynamic imports for code splitting.

// components/dialogs/index.ts
import dynamic from 'next/dynamic';
import type { DialogManagerConfig } from '@marmoui/ui';

export const appDialogConfig: DialogManagerConfig = {
  dialogs: {
    // Campaign dialogs
    deleteCampaign: dynamic(() => import('./delete-campaign-dialog'), { ssr: false }),
    createCampaign: dynamic(() => import('./create-campaign-dialog'), { ssr: false }),

    // Contact dialogs
    createContact: dynamic(() => import('./create-contact-dialog'), { ssr: false }),
    deleteContact: dynamic(() => import('./delete-contact-dialog'), { ssr: false }),
    sendEmail: dynamic(() => import('./send-email-dialog'), { ssr: false }),
    editContact: dynamic(() => import('./edit-contact-dialog'), { ssr: false }),
  },
  defaultConfig: {
    size: 'md',
    closeOnEscape: true,
    closeOnInteractOutside: true,
  },
};

Step 3: Wire Up the Provider

Wrap your app with DialogProvider and place DialogManager at the root.

// app/providers.tsx
import { DialogProvider, DialogManager } from '@marmoui/ui';
import { appDialogConfig } from '@/components/dialogs';

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <DialogProvider>
      {children}
      <DialogManager config={appDialogConfig} />
    </DialogProvider>
  );
}

Step 4: Open Dialogs from Anywhere

Call useDialog() in any component to get openDialog. Pass the dialog type string and any props the dialog component expects.

useDialog Hook

Open any registered dialog from any component with openDialog()

Click any button to open a dialog via useDialog():

import { useDialog, Button } from '@marmoui/ui';

function CampaignActions({ campaign }) {
const { openDialog } = useDialog();

return (
  <div className="flex gap-2">
    <Button
      variant="destructive"
      onClick={() => openDialog('deleteCampaign', {
        dialogTitle: `Delete "${campaign.name}"?`,
        dialogDescription: 'This action cannot be undone.',
        campaignName: campaign.name,
      })}
    >
      Delete
    </Button>
    <Button
      variant="primary"
      onClick={() => openDialog('createContact')}
    >
      Create Contact
    </Button>
    <Button
      variant="secondary"
      onClick={() => openDialog('sendEmail', {
        contactName: 'Jane Smith',
      })}
    >
      Send Email
    </Button>
  </div>
);
}

Dialog Stack

DialogStack renders multiple dialogs as a visual stack — previous dialogs appear as cards pushed behind the active one. Use DialogStackNext and DialogStackPrevious to navigate between them.

Stacked Dialogs

Navigate through multiple dialogs with a visual stack effect

<DialogStack>
<DialogStackTrigger asChild>
  <Button>Open Stacked Dialogs</Button>
</DialogStackTrigger>
<DialogStackOverlay />
<DialogStackBody>
  <DialogStackContent>
    <DialogStackHeader>
      <DialogStackTitle>Campaign Details</DialogStackTitle>
      <DialogStackDescription>Fill in the basics.</DialogStackDescription>
    </DialogStackHeader>
    <div className="py-4">
      <Input placeholder="Campaign name..." />
    </div>
    <DialogStackFooter>
      <DialogStackPrevious asChild>
        <Button variant="secondary" size="sm">Previous</Button>
      </DialogStackPrevious>
      <DialogStackNext asChild>
        <Button variant="primary" size="sm">Next</Button>
      </DialogStackNext>
    </DialogStackFooter>
  </DialogStackContent>

  <DialogStackContent>
    <DialogStackHeader>
      <DialogStackTitle>Select Audience</DialogStackTitle>
      <DialogStackDescription>Choose recipients.</DialogStackDescription>
    </DialogStackHeader>
    <div className="py-4">
      <Input placeholder="All subscribers" />
    </div>
    <DialogStackFooter>
      <DialogStackPrevious asChild>
        <Button variant="secondary" size="sm">Previous</Button>
      </DialogStackPrevious>
      <DialogStackNext asChild>
        <Button variant="primary" size="sm">Next</Button>
      </DialogStackNext>
    </DialogStackFooter>
  </DialogStackContent>

  <DialogStackContent>
    <DialogStackHeader>
      <DialogStackTitle>Review and Send</DialogStackTitle>
      <DialogStackDescription>Confirm and launch.</DialogStackDescription>
    </DialogStackHeader>
    <div className="py-4">
      <p className="text-sm text-ink-light">Ready to send.</p>
    </div>
    <DialogStackFooter>
      <DialogStackPrevious asChild>
        <Button variant="secondary" size="sm">Previous</Button>
      </DialogStackPrevious>
      <Button variant="primary" size="sm">Send Campaign</Button>
    </DialogStackFooter>
  </DialogStackContent>
</DialogStackBody>
</DialogStack>

Clickable Navigation

Set clickable on DialogStack to allow users to click previous (stacked) cards to navigate back.

Clickable Stack

Click previous dialog cards to navigate back

<DialogStack clickable>
<DialogStackTrigger asChild>
  <Button>Open Clickable Stack</Button>
</DialogStackTrigger>
<DialogStackOverlay />
<DialogStackBody>
  <DialogStackContent>...</DialogStackContent>
  <DialogStackContent>...</DialogStackContent>
  <DialogStackContent>...</DialogStackContent>
</DialogStackBody>
</DialogStack>

Dialog Config Options

Pass a third argument to openDialog to override the default config per dialog:

openDialog('manageFolders', { type: 'campaign' }, {
  size: 'lg',                       // Override size for this dialog
  closeOnInteractOutside: false,    // Prevent closing by clicking outside
  closeOnEscape: false,             // Prevent closing with Escape
  role: 'alertdialog',             // ARIA role for critical dialogs
});

Dynamic Title and Description

Every dialog automatically receives dialogTitle and dialogDescription from the props you pass to openDialog. This lets you customize the title per invocation:

// From a contacts table row action
openDialog('deleteContact', {
  dialogTitle: `Delete "${contact.email}"?`,
  dialogDescription: 'This action cannot be undone.',
  contactEmail: contact.email,
  contactId: contact.id,
});

// From a bulk action bar
openDialog('deleteContact', {
  dialogTitle: `Delete ${selectedContacts.length} contacts?`,
  dialogDescription: 'This will permanently remove all selected contacts.',
  contacts: selectedContacts,
});

Real-World Example

Here is a complete setup from the Marmo Campaign app showing how all the pieces fit together:

// 1. Root provider (app/providers.tsx)
import { DialogProvider, DialogManager } from '@marmoui/ui';
import { appDialogConfig } from '@/components/dialogs';

export function Providers({ children }) {
  return (
    <QueryClientProvider client={queryClient}>
      <DialogProvider>
        <SidebarProvider mainNavItems={navItems}>
          {children}
          <DialogManager config={appDialogConfig} />
        </SidebarProvider>
      </DialogProvider>
    </QueryClientProvider>
  );
}

// 2. Dialog registry (components/dialogs/index.ts)
import dynamic from 'next/dynamic';
import type { DialogManagerConfig } from '@marmoui/ui';

export const appDialogConfig: DialogManagerConfig = {
  dialogs: {
    deleteCampaign: dynamic(() => import('../campaigns/delete-campaign-dialog'), { ssr: false }),
    createCampaign: dynamic(() => import('../campaigns/create-campaign-dialog'), { ssr: false }),
    createContact: dynamic(() => import('../contacts/create-contact-dialog'), { ssr: false }),
    deleteContact: dynamic(() => import('../contacts/delete-contact-dialog'), { ssr: false }),
    sendEmail: dynamic(() => import('../contacts/send-email-dialog'), { ssr: false }),
    editContact: dynamic(() => import('../contacts/edit-contact-dialog'), { ssr: false }),
    addToList: dynamic(() => import('../contacts/add-to-list-dialog'), { ssr: false }),
    viewProfile: dynamic(() => import('../contacts/view-profile-dialog'), { ssr: false }),
    templatePreview: dynamic(() => import('../campaigns/template-preview-dialog')
      .then(mod => ({ default: mod.TemplatePreviewDialog })), { ssr: false }),
  },
  defaultConfig: {
    size: 'md',
    closeOnEscape: true,
    closeOnInteractOutside: true,
  },
};

// 3. Using in a data table row (components/contacts/contacts-list.tsx)
function ContactRowActions({ contact }) {
  const { openDialog } = useDialog();

  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <IconButton variant="ghost" size="sm"><MdMoreVert /></IconButton>
      </DropdownMenuTrigger>
      <DropdownMenuContent>
        <DropdownMenuItem onClick={() => openDialog('viewProfile', { contact })}>
          View Profile
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => openDialog('editContact', { contact })}>
          Edit Contact
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => openDialog('sendEmail', { contact })}>
          Send Email
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => openDialog('addToList', { contact })}>
          Add to List
        </DropdownMenuItem>
        <DropdownMenuItem
          variant="destructive"
          onClick={() => openDialog('deleteContact', {
            dialogTitle: `Delete "${contact.email}"?`,
            contactEmail: contact.email,
            contactId: contact.id,
          })}
        >
          Delete
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

Best Practices

Use no-header layout for confirmations

For simple confirmations and alerts, put DialogTitle and DialogDescription directly inside DialogContent — no DialogHeader needed.

Use DialogHeader for forms and settings

Use DialogHeader when content is richer — forms, settings panels, or any dialog where a persistent title bar helps orientation.

Use the registry for app-wide dialogs

Register all dialogs in a central config. This keeps dialog logic centralized and enables code splitting via dynamic imports.

Use action-oriented button labels

Use specific labels like "Delete Campaign", "Save Changes", "Send Email" instead of generic "Yes" or "OK".

Don't nest DialogProvider

Only wrap your app root with one DialogProvider. All components share the same dialog stack.

Don't render Dialog wrapper in registered components

Registered dialog components should render only the inner content (Header, body, Footer). DialogManager handles the outer Dialog and DialogContent wrapper.

Don't use for non-critical content

Dialogs interrupt the user. For informational messages, use Toast. For quick selections, use Popover or Dropdown.

Props

Dialog

PropTypeDefaultDescription
openbooleanControlled open state
onOpenChange(open: boolean) => voidCalled when open state changes
defaultOpenbooleanfalseUncontrolled initial open state
modalbooleantrueWhether to render as modal

DialogContent

PropTypeDefaultDescription
size'sm' | 'md' | 'lg' | 'xl' | 'full''md'Dialog width
showCloseButtonbooleantrueShow the X close button
classNamestringAdditional className

DialogConfig (for openDialog)

PropTypeDefaultDescription
size'sm' | 'md' | 'lg' | 'xl' | 'full''md'Dialog width
closeOnEscapebooleantrueAllow closing with Escape key
closeOnInteractOutsidebooleantrueAllow closing by clicking overlay
role'dialog' | 'alertdialog''dialog'ARIA role

useDialog

const {
  openDialog,       // (type: string, props?: object, config?: DialogConfig) => void
  closeDialog,      // () => void — closes the top dialog
  closeAllDialogs,  // () => void — clears the entire stack
  dialogStack,      // DialogEntry[] — current stack of open dialogs
} = useDialog();

DialogManagerConfig

interface DialogManagerConfig {
  dialogs: Record<string, ComponentType<any>>;  // Dialog registry
  defaultConfig?: {
    size?: 'sm' | 'md' | 'lg' | 'xl' | 'full';
    closeOnEscape?: boolean;
    closeOnInteractOutside?: boolean;
  };
}

Accessibility

Focus Management

Dialogs automatically trap focus and return it to the trigger element when closed. Ensure all interactive elements inside dialogs are keyboard accessible.

  • Focus is automatically trapped within the dialog
  • Pressing Escape closes the dialog (unless closeOnEscape: false)
  • Focus returns to the trigger element on close
  • Use DialogTitle for the dialog heading (renders as h2)
  • Provide DialogDescription for screen reader context
  • Use role="alertdialog" in config for critical confirmations
  • All buttons and inputs are keyboard navigable