PixelModal

Centered modal dialog with title bar, optional description and footer, surface-aware chrome, focus trap, scroll lock, and async close support.

  • Five sizes (sm/md/lg/xl/full) with surface-aware chrome — pixel renders an old-school window, linear a flat card
  • Focus trap, scroll lock, and Escape-to-close come built in
  • Optional description wired via aria-describedby and optional footer slot for actions
  • asyncClose awaits a promise (with loading affordance on the close button) before unmounting
  • Portals to document.body by default; accepts a custom container override
Status
stable
Category
overlays
Since
v1.0.0

API

import { PixelModal } from '@pxlkit/ui-kit';
PropTypeDefaultDescription
open (required)boolean—Whether the modal is currently visible.
title (required)string—Modal title shown in the header.
children (required)React.ReactNode—Modal body content.
onClose (required)() => void—Called when the user requests to close the modal.
size'sm' | 'md' | 'lg' | 'xl' | 'full''md'Width preset. Default 'md'.
surface'pixel' | 'linear'—Visual surface override. Falls back to nearest <PxlKitProvider> surface.
closeLabelstring'Close'Optional override for the close button's accessible label.
footerReact.ReactNode—Optional footer node, rendered at the bottom separated by a surface-aware divider.
descriptionReact.ReactNode—Optional description, wired via aria-describedby for AT users.
asyncClose() => Promise<void>—When provided, the close button awaits this promise (and shows a loading state) before the consumer-controlled onClose is invoked. Lets callers persist or animate-out before unmounting.
containerHTMLElement | null—Optional portal container override. Defaults to document.body.

ref points to <div>.

Accessibility

WCAG target: 2.1 AA

  • dialog

Renders with role="dialog" and aria-modal="true". Title is exposed via aria-labelledby; description (when provided) via aria-describedby. The close button advertises aria-busy while asyncClose is in flight. Backdrop click is equivalent to Escape and is disabled while closing to avoid double-firing.

Keyboard

KeyActionWhen
EscapeCloses the modal (awaits asyncClose if provided)—
TabCycles focus within the trapped dialog—
Shift+TabCycles focus backwards within the trapped dialog—

Usage

import { useState } from 'react';
import { PixelModal, PixelButton } from '@pxlkit/ui-kit';

export function Default() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <PixelButton onClick={() => setOpen(true)}>Open modal</PixelButton>
      <PixelModal open={open} onClose={() => setOpen(false)} title="Default modal">
        <p>This is a minimal modal with title and body content.</p>
      </PixelModal>
    </>
  );
}

Examples

Default

import { useState } from 'react';
import { PixelModal, PixelButton } from '@pxlkit/ui-kit';

export function Default() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <PixelButton onClick={() => setOpen(true)}>Open modal</PixelButton>
      <PixelModal open={open} onClose={() => setOpen(false)} title="Default modal">
        <p>This is a minimal modal with title and body content.</p>
      </PixelModal>
    </>
  );
}

Sizes

import { useState } from 'react';
import { PixelModal, PixelButton } from '@pxlkit/ui-kit';

export function Sizes() {
  const [size, setSize] = useState<'sm' | 'md' | 'lg' | 'xl' | 'full' | null>(null);
  return (
    <>
      <div className="flex flex-wrap gap-2">
        <PixelButton size="sm" onClick={() => setSize('sm')}>sm</PixelButton>
        <PixelButton size="sm" onClick={() => setSize('md')}>md</PixelButton>
        <PixelButton size="sm" onClick={() => setSize('lg')}>lg</PixelButton>
        <PixelButton size="sm" onClick={() => setSize('xl')}>xl</PixelButton>
        <PixelButton size="sm" onClick={() => setSize('full')}>full</PixelButton>
      </div>
      {size && (
        <PixelModal open onClose={() => setSize(null)} size={size} title={`Size: ${size}`}>
          <p>Modal rendered at size <code>{size}</code>.</p>
        </PixelModal>
      )}
    </>
  );
}

Surfaces

import { useState } from 'react';
import { PixelModal, PixelButton } from '@pxlkit/ui-kit';

export function Surfaces() {
  const [which, setWhich] = useState<'pixel' | 'linear' | null>(null);
  return (
    <>
      <div className="flex flex-wrap gap-2">
        <PixelButton onClick={() => setWhich('pixel')}>Pixel surface</PixelButton>
        <PixelButton onClick={() => setWhich('linear')}>Linear surface</PixelButton>
      </div>
      {which && (
        <PixelModal open onClose={() => setWhich(null)} surface={which} title={`${which} surface`}>
          <p>The {which} surface uses surface-aware borders, dividers, and chrome.</p>
        </PixelModal>
      )}
    </>
  );
}

With description

import { useState } from 'react';
import { PixelModal, PixelButton } from '@pxlkit/ui-kit';

export function WithDescription() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <PixelButton onClick={() => setOpen(true)}>Open with description</PixelButton>
      <PixelModal
        open={open}
        onClose={() => setOpen(false)}
        title="Confirm action"
        description="This description is wired via aria-describedby for assistive tech."
      >
        <p>Body content sits below the description.</p>
      </PixelModal>
    </>
  );
}

Async close

import { useState } from 'react';
import { PixelModal, PixelButton } from '@pxlkit/ui-kit';

export function AsyncClose() {
  const [open, setOpen] = useState(false);
  const asyncClose = () => new Promise<void>((resolve) => setTimeout(resolve, 800));
  return (
    <>
      <PixelButton onClick={() => setOpen(true)}>Open async-close</PixelButton>
      <PixelModal
        open={open}
        onClose={() => setOpen(false)}
        asyncClose={asyncClose}
        title="Persisting…"
      >
        <p>The close button awaits the asyncClose promise before unmounting.</p>
      </PixelModal>
    </>
  );
}

Custom close label

import { useState } from 'react';
import { PixelModal, PixelButton } from '@pxlkit/ui-kit';

export function CustomCloseLabel() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <PixelButton onClick={() => setOpen(true)}>Open with custom close label</PixelButton>
      <PixelModal
        open={open}
        onClose={() => setOpen(false)}
        title="Localized"
        closeLabel="Cerrar"
      >
        <p>The close button announces a custom accessible label.</p>
      </PixelModal>
    </>
  );
}

Related