# Modal

> An accessible dialog with backdrop, focus trap, and escape-to-close. Controlled component - you own the `open` state.

- Category: overlay
- Status: stable (since 1.0.0)
- A11y pattern: https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/
- Tokens: --background-primary, --background-secondary, --foreground-primary, --foreground-muted, --cta-background
- Playground: https://design.freecodecamp.org/playground#modal
- npm dependencies: `react@>=18 <20`, `@ark-ui/react@^5.0.0`
- Registry dependencies: [theme](https://design.freecodecamp.org/registry/theme.md)
- Files:
  - `Modal.tsx` → `src/ui/modal/Modal.tsx` (raw: https://design.freecodecamp.org/registry/modal/Modal.tsx)
  - `modal.css` → `src/ui/modal/modal.css` (raw: https://design.freecodecamp.org/registry/modal/modal.css)

## Install (copy source)

1. Ensure the theme is installed once per project - tokens.css + base.css imported globally, fonts available. See https://design.freecodecamp.org/registry/theme.md and https://design.freecodecamp.org/registry/starter.md.
2. Copy the files below into `src/ui/modal/` (adjust to your project layout) and import the CSS once from your global stylesheet, e.g. `@import './ui/modal/modal.css';`.
3. Colors, spacing and type come from tokens - tailor the component by editing the copied source; recolour by editing tokens.css, not the component CSS.

## Usage

`<Modal>` is the accessible dialog primitive. It locks focus, closes on
Escape or backdrop click, and carries the correct `role='dialog'` +
`aria-modal` wiring.

## Usage

```tsx
import { useState } from 'react';
import { Modal } from './ui/modal/Modal';
function Confirm() {
  const [open, setOpen] = useState(false);
  return (
    <>
      <button onClick={() => setOpen(true)}>Delete account</button>
      <Modal
        open={open}
        onClose={() => setOpen(false)}
        title='Delete your account?'
      >
        <p>This cannot be undone.</p>
        <Modal.Footer>
          <button onClick={() => setOpen(false)}>Cancel</button>
          <button className='btn btn--danger'>Delete</button>
        </Modal.Footer>
      </Modal>
    </>
  );
}
```

## Props

`Modal.Footer` slots action buttons along the bottom of the dialog.

## Keyboard

| Key    | Action                                   |
| ------ | ---------------------------------------- |
| Tab    | Cycles focus inside the dialog (trapped) |
| Escape | Calls `onClose`                          |
| Enter  | Activates the focused action             |

## Accessibility

Modal renders `role='dialog'` with `aria-modal='true'`. When you provide
`title`, it becomes the dialog's accessible name. Body scroll is locked
while `open` is true. Return focus to the triggering element on close -
that is the caller's job. Keep destructive flows one click deep: confirm
delete, then act.

## Example

```tsx
import { Modal } from './ui/modal/Modal';
import { Button } from './ui/button/Button';

<Modal open={open} onClose={close} title="Reset progress?">
  <Modal.Body>
    You completed 17 of 32 steps in Responsive Web Design.
  </Modal.Body>
  <Modal.Footer>
    <Button onClick={close}>Cancel</Button>
    <Button variant="danger" onClick={reset}>Reset progress</Button>
  </Modal.Footer>
</Modal>
```

## Props

| Prop | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `open` | `boolean` | yes | - |  |
| `onClose` | `() => void` | yes | - |  |
| `title` | `ReactNode` | no | - |  |
| `closeOnBackdrop` | `boolean` | no | `true` |  |

## Source: Modal.tsx

```tsx
import React from 'react';
import { Dialog } from '@ark-ui/react/dialog';

export interface ModalProps extends Omit<
  React.HTMLAttributes<HTMLDivElement>,
  'onChange' | 'title'
> {
  open: boolean;
  onClose: () => void;
  title?: React.ReactNode;
  closeOnBackdrop?: boolean;
}

const ModalRoot = ({
  open,
  onClose,
  title,
  closeOnBackdrop = true,
  className = '',
  children,
  ...rest
}: ModalProps) => {
  const panelClasses = ['modal__panel', className].filter(Boolean).join(' ');

  return (
    <Dialog.Root
      open={open}
      onOpenChange={details => {
        if (!details.open) onClose();
      }}
      closeOnInteractOutside={closeOnBackdrop}
      lazyMount={false}
      unmountOnExit
    >
      <Dialog.Backdrop className='modal__backdrop' />
      <Dialog.Positioner className='modal'>
        <Dialog.Content className={panelClasses} {...rest}>
          {title !== undefined && (
            <header className='modal__header'>
              <Dialog.Title className='modal__title'>{title}</Dialog.Title>
              <Dialog.CloseTrigger className='close-btn' aria-label='Close'>
                {'×'}
              </Dialog.CloseTrigger>
            </header>
          )}
          {children}
        </Dialog.Content>
      </Dialog.Positioner>
    </Dialog.Root>
  );
};
ModalRoot.displayName = 'Modal';

const ModalHeader = ({
  className = '',
  children,
  ...rest
}: React.HTMLAttributes<HTMLElement>) => (
  <header
    className={['modal__header', className].filter(Boolean).join(' ')}
    {...rest}
  >
    {children}
  </header>
);
ModalHeader.displayName = 'Modal.Header';

const ModalBody = ({
  className = '',
  children,
  ...rest
}: React.HTMLAttributes<HTMLDivElement>) => (
  <div
    className={['modal__body', className].filter(Boolean).join(' ')}
    {...rest}
  >
    {children}
  </div>
);
ModalBody.displayName = 'Modal.Body';

const ModalFooter = ({
  className = '',
  children,
  ...rest
}: React.HTMLAttributes<HTMLElement>) => (
  <footer
    className={['modal__footer', className].filter(Boolean).join(' ')}
    {...rest}
  >
    {children}
  </footer>
);
ModalFooter.displayName = 'Modal.Footer';

export const Modal = Object.assign(ModalRoot, {
  Header: ModalHeader,
  Body: ModalBody,
  Footer: ModalFooter
});
```

## Source: modal.css

```css
.modal__backdrop {
  position: fixed;
  inset: 0;
  z-index: calc(var(--z-modal) - 1);
  background: rgba(0, 0, 0, 0.55);
  opacity: 0;
  transition: opacity var(--dur-fast) var(--ease-out);
}
.modal__backdrop[data-state='open'] {
  opacity: 1;
}
.modal {
  position: fixed;
  inset: 0;
  z-index: var(--z-modal);
  display: none;
  align-items: flex-start;
  justify-content: center;
  padding: 48px 16px;
  overflow: auto;
}
.modal[data-open='true'],
.modal--open,
.modal:has(> [data-state='open']) {
  display: flex;
}
.modal__panel {
  background: var(--background-secondary);
  border: 1px solid var(--background-quaternary);
  width: 100%;
  max-width: 560px;
  opacity: 0;
  transform: scale(0.96);
  transition:
    opacity var(--dur-fast) var(--ease-out),
    transform var(--dur-slow) var(--ease-out);
}
.modal__panel[data-state='open'] {
  opacity: 1;
  transform: scale(1);
}
.modal__panel[data-state='closed'] {
  pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
  .modal__backdrop,
  .modal__panel {
    transition: none;
  }
}
.modal__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 14px 16px;
  border-bottom: 1px solid var(--background-quaternary);
}
.modal__title {
  margin: 0;
  font-size: var(--fs-lg);
}
.modal__body {
  padding: 16px;
}
.modal__footer {
  padding: 14px 16px;
  border-top: 1px solid var(--background-quaternary);
  display: flex;
  justify-content: flex-end;
  gap: 8px;
}
```

## HTML / vanilla variant

```html
<div class="modal" role="dialog" aria-labelledby="m-title">
  <header class="modal__header">
    <p class="modal__title" id="m-title">Reset progress?</p>
    <button class="close-btn" aria-label="Close">×</button>
  </header>
  <div class="modal__body"><p>...</p></div>
  <footer class="modal__footer">
    <button class="btn">Cancel</button>
    <button class="btn btn--danger">Reset progress</button>
  </footer>
</div>
```

Interactive behaviours for plain HTML come from the vanilla runtime (data-uikit-* attributes): https://design.freecodecamp.org/registry/vanilla.md - or download https://design.freecodecamp.org/cdn/uikit.global.js once and self-host it (do not hotlink).

## For coding agents

This library is distributed as copyable source, not an npm package. Start at https://design.freecodecamp.org/registry/starter.md, discover components via https://design.freecodecamp.org/llms.txt, and copy files into the consuming project. Keep token names intact; recolour by editing the copied tokens.css.
