Agents (llms.txt)

Modal

Modal provides an unstyled basis to display content in a layer above the main interface.

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

const ModalPreview = () => {
  return (
    <Modal>
      <Modal.Trigger asChild>
        <Button variant="outline">Open</Button>
      </Modal.Trigger>
      <Modal.Content className="min-w-xs space-y-6">
        <div className="space-y-1">
          <Modal.Title className="font-semibold">Title</Modal.Title>
          <Modal.Description>Description</Modal.Description>
        </div>
        <Modal.Close asChild>
          <Button variant="outline">Close</Button>
        </Modal.Close>
      </Modal.Content>
    </Modal>
  );
};

export default ModalPreview;

Dependencies

Source Code

'use client';

import * as motion from 'motion/react-client';
import {
  Children,
  createContext,
  isValidElement,
  use,
  useCallback,
  useEffect,
  useId,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
} from 'react';
import { Slot } from '@/components/slot';
import { useElementTransition } from '@/foundations/hooks/use-element-transition';
import {
  MOTION_DURATION,
  MOTION_EASE,
  MOTION_SCALE,
} from '@/foundations/setup/motion';
import { composeRefs } from '@/lib/compose-refs';
import { SafeAnimatePresence } from '@/lib/utils/animate-presence-safe';
import { cn } from '@/lib/utils/classnames';

// Hook to manage native <dialog> element behavior
const useDialogElement = (
  open: boolean,
  setOpen: (isOpen: boolean) => void
) => {
  const ref = useRef<HTMLDialogElement>(null);

  // Emit modal-open or modal-close custom event when the dialog is opened or closed,
  // so other components can react to it
  useEffect(() => {
    const origin = ref.current;
    if (!origin) return;

    const openEvent = new CustomEvent(
      open ? 'ui:modal-open' : 'ui:modal-close',
      {
        detail: { origin: ref.current },
      }
    );
    window.dispatchEvent(openEvent);
  }, [open]);

  useLayoutEffect(() => {
    const element = ref.current;
    if (!element) return;

    if (!element.open) {
      // use native showModal() to ensure it receives focus when opened, and ESC closes it
      element.showModal();
    }

    const abortController = new AbortController();
    const { signal } = abortController;

    // Prevent the default cancel event and use internal state to close the drawer instead
    // This ensures drawer closing is synchronized with internal state, preventing layout shifts
    element.addEventListener(
      'cancel',
      (event: Event) => {
        event.preventDefault();
        setOpen(false);
      },
      { signal }
    );

    // Prevent ESC from closing the dialog when the cancel event is prevented.
    // Unsure if this is a browser bug or intended behavior — the ESC key can push through the cancel event for some reason
    element.addEventListener(
      'keydown',
      (event: KeyboardEvent) => {
        if (event.key === 'Escape' && open) {
          event.preventDefault();
          setOpen(false);
        }
      },
      { signal }
    );

    // Prevent ESC from closing the dialog when it is inert.
    // If the dialog is opened while inert, the focus goes to the window, which allows ESC to close the dialog unexpectedly.
    window.addEventListener(
      'keydown',
      (event: KeyboardEvent) => {
        if (event.key === 'Escape' && element.inert && open) {
          event.preventDefault();
          setOpen(false);
        }
      },
      { signal }
    );

    return () => {
      abortController.abort();
    };
  }, [open, setOpen]);

  useEffect(() => {
    const element = ref.current;
    if (!element || !open) return;

    const handleDialogClick = (event: MouseEvent) => {
      // if the click is on the backdrop, close the drawer
      if ((event.target as HTMLElement).nodeName === 'DIALOG') {
        const dialog = event.target as HTMLDialogElement;
        const { top, left, width, height } = dialog.getBoundingClientRect();
        const isOutsideModal =
          top > event.clientY ||
          event.clientY > top + height ||
          left > event.clientX ||
          event.clientX > left + width;

        if (isOutsideModal) {
          event.stopPropagation();
          setOpen(false);
        }
      }
    };

    element.addEventListener('click', handleDialogClick);

    return () => {
      element.removeEventListener('click', handleDialogClick);
    };
  }, [setOpen, open]);

  return ref;
};

const ModalContext = createContext<{
  open: boolean;
  labelId?: string;
  descriptionId?: string;
  setOpen: (open: boolean) => void;
  setLabelId: (id?: string) => void;
  setDescriptionId: (id?: string) => void;
} | null>(null);

const useModalContext = () => {
  const context = use(ModalContext);

  if (!context) {
    throw new Error('Modal component must be used within a Modal');
  }

  return context;
};

interface ModalProps {
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
  children: React.ReactNode;
}

const Modal = ({ open: propsOpen, onOpenChange, children }: ModalProps) => {
  const [internalOpen, setInternalOpen] = useState(false);
  const [labelId, setLabelId] = useState<string | undefined>(undefined);
  const [descriptionId, setDescriptionId] = useState<string | undefined>(
    undefined
  );

  const open = propsOpen ?? internalOpen;

  const setOpen = useCallback(
    (isOpen: boolean) => {
      setInternalOpen(isOpen);
      onOpenChange?.(isOpen);
    },
    [onOpenChange]
  );

  const ctx = useMemo(
    () => ({
      open,
      setOpen,
      labelId,
      setLabelId,
      descriptionId,
      setDescriptionId,
    }),
    [descriptionId, labelId, open, setOpen]
  );

  return <ModalContext value={ctx}>{children}</ModalContext>;
};

interface ModalContentProps extends React.ComponentPropsWithRef<'dialog'> {
  catchFocus?: boolean;
}

const ModalContent = ({
  className,
  children,
  catchFocus = true,
  ...props
}: ModalContentProps) => {
  const { open, labelId, descriptionId, setOpen } = useModalContext();
  const dialogRef = useDialogElement(open, setOpen);

  const {
    ref: transitionRef,
    isMounted,
    status,
  } = useElementTransition<HTMLDialogElement>(open);

  if (!isMounted) return;

  return (
    <dialog
      ref={composeRefs(dialogRef, transitionRef)}
      data-status={status}
      data-motion="modal"
      aria-labelledby={labelId}
      aria-describedby={descriptionId}
      className={cn(
        // Family defaults: 20px radius, 24px padding, no border, CK modal shadow.
        // Examples can override any of these via className (which lands last).
        'm-auto rounded-[1.25rem] bg-surface-raised p-6 shadow-[var(--shadow-modal)]',
        // No default backdrop blur — CK uses transparent slate dim only.
        'backdrop:bg-(--color-overlay) not-data-[status=open]:backdrop:opacity-0 backdrop:transition-opacity backdrop:duration-(--duration-box) backdrop:ease-out',
        'motion-reduce:backdrop:transition-none motion-reduce:[animation:none]',
        className
      )}
      {...props}
    >
      {catchFocus && (
        // By default, the HTML <dialog> element focuses the first focusable child when opened.
        // If that element is scrolled out of view, the dialog may jump to it, causing a jarring and confusing scroll.
        // Additionally, browsers like Safari may show focus-visible styles on that element, which can look odd.
        // The following element catches initial focus to prevent these issues.
        <div
          className="sr-only"
          autoFocus
          tabIndex={-1}
          data-modal-focus-catcher=""
        />
      )}
      {children}
    </dialog>
  );
};

interface ModalTriggerProps extends React.ComponentPropsWithRef<'button'> {
  asChild?: boolean;
}

const ModalTrigger = ({ asChild, children, ...props }: ModalTriggerProps) => {
  const { setOpen } = useModalContext();

  const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
    props.onClick?.(event);

    if (!event.defaultPrevented) {
      setOpen(true);
    }
  };

  const Component = asChild ? Slot : 'button';

  return (
    <Component {...props} onClick={handleClick}>
      {children}
    </Component>
  );
};

interface ModalCloseProps extends React.ComponentPropsWithRef<'button'> {
  asChild?: boolean;
}

const ModalClose = ({
  asChild = false,
  children,
  ...props
}: ModalCloseProps) => {
  const { setOpen } = useModalContext();

  const handleClick = (event: React.MouseEvent<HTMLElement>) => {
    props.onClick?.(event as React.MouseEvent<HTMLButtonElement>);

    if (!event.defaultPrevented) {
      setOpen(false);
    }
  };

  const Component = asChild ? Slot : 'button';
  return (
    <Component {...props} onClick={handleClick}>
      {children}
    </Component>
  );
};

interface ModalTitleProps extends React.ComponentPropsWithRef<'h2'> {
  asChild?: boolean;
}

const ModalTitle = ({ children, asChild, ...props }: ModalTitleProps) => {
  const generatedId = useId();
  const id = props.id ?? generatedId;

  const { setLabelId } = useModalContext();

  useLayoutEffect(() => {
    setLabelId(id);

    return () => setLabelId(undefined);
  }, [id, setLabelId]);

  const Component = asChild ? Slot : 'h2';
  return (
    <Component id={id} {...props}>
      {children}
    </Component>
  );
};

interface ModalDescriptionProps extends React.ComponentPropsWithRef<'p'> {
  asChild?: boolean;
}

const ModalDescription = ({
  children,
  asChild,
  className,
  ...props
}: ModalDescriptionProps) => {
  const generatedId = useId();
  const id = props.id ?? generatedId;

  const { setDescriptionId } = useModalContext();

  useLayoutEffect(() => {
    setDescriptionId(id);

    return () => setDescriptionId(undefined);
  }, [id, setDescriptionId]);

  const Component = asChild ? Slot : 'p';
  return (
    <Component
      id={id}
      // CK body copy is muted by default; <strong> flips to primary foreground.
      className={cn(
        'text-base text-foreground-secondary leading-[21px] [&_strong]:font-medium [&_strong]:text-foreground',
        className
      )}
      {...props}
    >
      {children}
    </Component>
  );
};

// ---------------------------------------------------------------------------
// Modal.Pages + Modal.Page — direction-aware step transitions
// ---------------------------------------------------------------------------
// Ports ConnectKit's modal page-swap (Modal/index.tsx + Modal/styles.ts).
// When the activeId changes, the leaving page exits and the entering page
// enters with one of four animations, chosen by direction:
//
//   forward (depth ↑): old page FadeOutScaleUp (1 → 1.1)
//                      new page FadeInScaleUp  (0.85 → 1)
//   back    (depth ↓): old page FadeOutScaleDown (1 → 0.85)
//                      new page FadeInScaleDown  (1.1 → 1)
//
// Depth is inferred from the children's order unless an explicit `depth`
// map is provided.

interface ModalPageProps {
  /** Stable ID — must match the value passed to `Modal.Pages` `activeId`. */
  id: string;
  children: React.ReactNode;
}

// Modal.Page is a marker component — actual rendering happens inside
// Modal.Pages so AnimatePresence can drive the transition.
const ModalPage = ({ children }: ModalPageProps) => <>{children}</>;
ModalPage.displayName = 'Modal.Page';

interface ModalPagesProps extends React.ComponentPropsWithRef<'div'> {
  activeId: string;
  /** Optional explicit depth map. Defaults to children-order index. */
  depth?: Record<string, number>;
  children: React.ReactNode;
}

// CK animation timings — `Page` uses `200ms ease both`, exit gets a tiny delay.
// (Modal/styles.ts: animation: 200ms ease both; Page exit has 16.67ms delay.)
const PAGE_TRANSITION = {
  duration: MOTION_DURATION.base,
  ease: MOTION_EASE.default,
};

const ModalPages = ({
  activeId,
  depth: explicitDepth,
  className,
  children,
  ...props
}: ModalPagesProps) => {
  const pages = useMemo(() => {
    return Children.toArray(children).filter(
      (c): c is React.ReactElement<ModalPageProps> => {
        return (
          isValidElement<ModalPageProps>(c) && typeof c.props.id === 'string'
        );
      }
    );
  }, [children]);

  const depthMap = useMemo(() => {
    if (explicitDepth) return explicitDepth;
    const map: Record<string, number> = {};
    pages.forEach((p, i) => {
      map[p.props.id] = i;
    });
    return map;
  }, [pages, explicitDepth]);

  const prevIdRef = useRef(activeId);
  const currentDepth = depthMap[activeId] ?? 0;
  const prevDepth = depthMap[prevIdRef.current] ?? 0;
  // direction: 1 = forward (deeper), -1 = back (shallower)
  const direction: 1 | -1 = currentDepth >= prevDepth ? 1 : -1;

  useEffect(() => {
    prevIdRef.current = activeId;
  }, [activeId]);

  const activePage = pages.find((p) => p.props.id === activeId);

  return (
    // `grid` lets entering + leaving pages stack in the same cell so the
    // modal sizes to whichever is rendering, without absolute positioning.
    <div className={cn('relative grid', className)} {...props}>
      <SafeAnimatePresence mode="popLayout" initial={false} custom={direction}>
        {activePage && (
          <motion.div
            key={activeId}
            data-modal-page={activeId}
            className="col-start-1 row-start-1"
            custom={direction}
            variants={{
              enter: (d: 1 | -1) => ({
                opacity: 0,
                // forward: FadeInScaleUp (0.85 → 1)
                // back:    FadeInScaleDown (1.1 → 1)
                // ConnectKit page transition — sub-modal scale entry, not family motion.
                scale: d === 1 ? 0.85 : MOTION_SCALE.enterDown,
              }),
              center: { opacity: 1, scale: 1 },
              exit: (d: 1 | -1) => ({
                opacity: 0,
                // forward: FadeOutScaleUp (1 → 1.1)
                // back:    FadeOutScaleDown (1 → 0.85)
                // ConnectKit page transition — sub-modal scale entry, not family motion.
                scale: d === 1 ? MOTION_SCALE.enterDown : 0.85,
              }),
            }}
            initial="enter"
            animate="center"
            exit="exit"
            transition={PAGE_TRANSITION}
          >
            {activePage.props.children}
          </motion.div>
        )}
      </SafeAnimatePresence>
    </div>
  );
};

const CompoundModal = Object.assign(Modal, {
  Content: ModalContent,
  Trigger: ModalTrigger,
  Close: ModalClose,
  Title: ModalTitle,
  Description: ModalDescription,
  Pages: ModalPages,
  Page: ModalPage,
});

export { CompoundModal as Modal };

CSS

To prevent background scrolling when the modal is open, add the following CSS to your global styles:


          body:has(dialog[open]) {
  overflow: hidden;
  scrollbar-gutter: stable;
}
        

Features

  • Native Dialog: Built on top of the HTML <dialog> element for optimal accessibility and base functionality
  • Backdrop Interaction: Click outside to close, ESC key support
  • Focus Management: Automatic focus trapping and restoration
  • Smooth Transitions: Integrated with useElementTransition for animations
  • Controlled & Uncontrolled: Supports both controlled and uncontrolled modes

Anatomy


          <Modal>
  <Modal.Trigger />
  <Modal.Content>
    <Modal.Title />
    <Modal.Description />
    <Modal.Close />
  </Modal.Content>
</Modal>
        

API Reference

Prop Default Type Description
open - boolean Controls the open state of the modal (controlled mode)
onOpenChange - (open: boolean) => void Callback fired when the open state changes
children * - ReactNode The modal components

Modal.Content

Extends the native <dialog> element.

Prop Default Type Description
catchFocus true boolean Whether to catch initial focus to prevent scroll jumps

Modal.Trigger

Extends the button element.

Prop Default Type Description
asChild false boolean Render as a child component instead of an h2

Modal.Close

Extends the button element.

Prop Default Type Description
asChild false boolean Render as a child component instead of an h2

Modal.Title

Extends the h2 element.

Prop Default Type Description
asChild false boolean Render as a child component instead of an h2

Modal.Description

Extends the p element.

Prop Default Type Description
asChild false boolean Render as a child component instead of a p

Modal.Pages

Wraps a set of Modal.Page children and animates between them with direction-aware enter/exit transitions ported from ConnectKit.

Prop Default Type Description
activeId - string ID of the page currently visible. Must match the `id` of one Modal.Page child.
depth - Record<string, number> Optional explicit depth map. Defaults to children-array order. Higher depth = deeper in the flow.

Modal.Page

A single step inside a Modal.Pages container. Renders only when its id matches the parent’s activeId.

Prop Default Type Description
id - string Stable identifier — used by Modal.Pages to determine the active page.

Examples

Basic Modal

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

const ModalPreview = () => {
  return (
    <Modal>
      <Modal.Trigger asChild>
        <Button variant="outline">Open</Button>
      </Modal.Trigger>
      <Modal.Content className="min-w-xs space-y-6">
        <div className="space-y-1">
          <Modal.Title className="font-semibold">Title</Modal.Title>
          <Modal.Description>Description</Modal.Description>
        </div>
        <Modal.Close asChild>
          <Button variant="outline">Close</Button>
        </Modal.Close>
      </Modal.Content>
    </Modal>
  );
};

export default ModalPreview;

Controlled Modal

import { useState } from 'react';

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

const ModalControlledPreview = () => {
  const [open, setOpen] = useState(false);
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleSubmit = () => {
    setIsSubmitting(true);

    setTimeout(() => {
      setIsSubmitting(false);
      setOpen(false);
    }, 3000);
  };

  const handleCancel = () => {
    setOpen(false);
  };

  return (
    <Modal
      open={open}
      onOpenChange={(isOpen) => setOpen(isSubmitting ? true : isOpen)}
    >
      <Button onClick={() => setOpen(true)} variant="destructive">
        Delete
      </Button>
      <Modal.Content inert={isSubmitting}>
        <Modal.Title className="my-1 font-semibold">Delete Account</Modal.Title>
        <p>Are you sure you want to proceed?</p>
        <div className="mt-4 flex gap-2">
          <Button
            isLoading={isSubmitting}
            onClick={handleSubmit}
            variant="destructive"
          >
            Delete
          </Button>
          <Button onClick={handleCancel}>Cancel</Button>
        </div>
      </Modal.Content>
    </Modal>
  );
};

export default ModalControlledPreview;

Multi-step (Pages)

import { useState } from 'react';

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

type Step = 'welcome' | 'details' | 'confirm';

const ModalPagesPreview = () => {
  const [open, setOpen] = useState(false);
  const [step, setStep] = useState<Step>('welcome');

  // Reset to welcome when modal opens
  const handleOpenChange = (next: boolean) => {
    setOpen(next);
    if (next) setStep('welcome');
  };

  return (
    <Modal open={open} onOpenChange={handleOpenChange}>
      <Modal.Trigger asChild>
        <Button variant="muted">Start flow</Button>
      </Modal.Trigger>
      <Modal.Content className="min-w-xs">
        <Modal.Pages activeId={step}>
          <Modal.Page id="welcome">
            <div className="space-y-4 text-center">
              <Modal.Title className="font-semibold text-lg">
                Welcome
              </Modal.Title>
              <Modal.Description>
                Step 1 of 3 — let's get you set up. Click{' '}
                <strong>Continue</strong> to go deeper. Watch the scale-up
                animation.
              </Modal.Description>
              <Button
                variant="muted"
                className="w-full"
                onClick={() => setStep('details')}
              >
                Continue
              </Button>
            </div>
          </Modal.Page>

          <Modal.Page id="details">
            <div className="space-y-4 text-center">
              <Modal.Title className="font-semibold text-lg">
                Details
              </Modal.Title>
              <Modal.Description>
                Step 2 of 3 — going back uses <strong>scale-down</strong>
                going forward uses <strong>scale-up</strong>.
              </Modal.Description>
              <div className="flex gap-2">
                <Button
                  variant="brandSoft"
                  className="grow"
                  onClick={() => setStep('welcome')}
                >
                  Back
                </Button>
                <Button
                  variant="muted"
                  className="grow"
                  onClick={() => setStep('confirm')}
                >
                  Continue
                </Button>
              </div>
            </div>
          </Modal.Page>

          <Modal.Page id="confirm">
            <div className="space-y-4 text-center">
              <Modal.Title className="font-semibold text-lg">
                All set!
              </Modal.Title>
              <Modal.Description>
                Step 3 of 3 — back will scale down to the previous step.
              </Modal.Description>
              <div className="flex gap-2">
                <Button
                  variant="brandSoft"
                  className="grow"
                  onClick={() => setStep('details')}
                >
                  Back
                </Button>
                <Modal.Close asChild>
                  <Button variant="muted" className="grow">
                    Done
                  </Button>
                </Modal.Close>
              </div>
            </div>
          </Modal.Page>
        </Modal.Pages>
      </Modal.Content>
    </Modal>
  );
};

export default ModalPagesPreview;

Modal.Pages swaps between Modal.Page children with direction-aware transitions. Going forward (deeper) uses FadeInScaleUp (0.85 → 1) for the entering page and FadeOutScaleUp (1 → 1.1) for the leaving one; going back uses the inverted scales. Direction is inferred from the order of Modal.Page children unless an explicit depth map is passed.

Motion

Modal.Pages uses a deliberate one-off scale literal (0.85) for the forward enter and back exit phases of its sub-modal page transition. This is a ConnectKit page-transition value, not family motion — it sits intentionally outside MOTION_SCALE because it tunes the depth feel of nested page swaps rather than the system-wide enter/exit pop. Per §10 of the motion guide, it’s documented here as a literal-without-token rather than promoted to a shared scale.

The complementary scale (MOTION_SCALE.enterDown, 1.1) is used for the back-enter and forward-exit phases so the motion still reads as inversion across direction.

Best Practices

  1. Accessibility:

    • Always provide Modal.Title for screen readers
    • Use Modal.Description for additional context
    • Ensure keyboard navigation works properly
  2. User Experience:

    • Keep modal content focused and concise
    • Provide clear actions (confirm/cancel)

Previous

Menu

Next

Nav