Expandable Dialog

A shared-layout dialog that expands list items into an accessible detail view.

dialogshared-layout
"use client";

import * as React from "react";
import {
  AnimatePresence,
  LayoutGroup,
  motion,
  useReducedMotion,
} from "motion/react";

import { cn } from "@/lib/utils";

export type ExpandableModalItem = {
  id: string;
  title: string;
  description: string;
  content: React.ReactNode;
  image: string;
  imageAlt?: string;
  actionLabel?: string;
};

export type ExpandableModalProps = Omit<
  React.ComponentPropsWithoutRef<"div">,
  "children" | "defaultValue" | "onChange" | "value"
> & {
  items?: readonly ExpandableModalItem[];
  value?: ExpandableModalItem | null;
  defaultValue?: ExpandableModalItem | null;
  onValueChange?: (item: ExpandableModalItem | null) => void;
  onAction?: (item: ExpandableModalItem) => void;
  actionLabel?: string;
  modalLabel?: string;
  listClassName?: string;
  itemClassName?: string;
};

function svgDataUri(svg: string) {
  return `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
}

const svgNamespace = ["http", "://www.w3.org/2000/svg"].join("");

const coverImages = {
  analytics: svgDataUri(`
    <svg xmlns="${svgNamespace}" viewBox="0 0 120 120">
      <defs>
        <linearGradient id="analytics-bg" x1="0" x2="1" y1="0" y2="1">
          <stop offset="0" stop-color="#0f172a"/>
          <stop offset="1" stop-color="#0f766e"/>
        </linearGradient>
      </defs>
      <rect width="120" height="120" rx="24" fill="url(#analytics-bg)"/>
      <rect x="24" y="26" width="72" height="68" rx="12" fill="#ecfeff" opacity=".92"/>
      <path d="M36 76l14-16 13 8 21-27" fill="none" stroke="#0f766e" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/>
      <circle cx="36" cy="76" r="5" fill="#14b8a6"/>
      <circle cx="50" cy="60" r="5" fill="#14b8a6"/>
      <circle cx="63" cy="68" r="5" fill="#14b8a6"/>
      <circle cx="84" cy="41" r="5" fill="#14b8a6"/>
    </svg>
  `),
  workflow: svgDataUri(`
    <svg xmlns="${svgNamespace}" viewBox="0 0 120 120">
      <defs>
        <linearGradient id="workflow-bg" x1="0" x2="1" y1="0" y2="1">
          <stop offset="0" stop-color="#312e81"/>
          <stop offset="1" stop-color="#0369a1"/>
        </linearGradient>
      </defs>
      <rect width="120" height="120" rx="24" fill="url(#workflow-bg)"/>
      <rect x="25" y="31" width="32" height="23" rx="8" fill="#e0f2fe"/>
      <rect x="63" y="31" width="32" height="23" rx="8" fill="#bfdbfe"/>
      <rect x="25" y="67" width="32" height="23" rx="8" fill="#a7f3d0"/>
      <rect x="63" y="67" width="32" height="23" rx="8" fill="#fde68a"/>
      <path d="M57 42h6M41 54v13M79 54v13M57 78h6" stroke="#f8fafc" stroke-width="5" stroke-linecap="round"/>
    </svg>
  `),
  release: svgDataUri(`
    <svg xmlns="${svgNamespace}" viewBox="0 0 120 120">
      <defs>
        <linearGradient id="release-bg" x1="0" x2="1" y1="0" y2="1">
          <stop offset="0" stop-color="#164e63"/>
          <stop offset="1" stop-color="#4d7c0f"/>
        </linearGradient>
      </defs>
      <rect width="120" height="120" rx="24" fill="url(#release-bg)"/>
      <path d="M60 22l27 15v31c0 19-11 30-27 36-16-6-27-17-27-36V37z" fill="#ecfccb" opacity=".9"/>
      <path d="M47 62l9 9 19-24" fill="none" stroke="#166534" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
      <path d="M32 92h56" stroke="#d9f99d" stroke-width="6" stroke-linecap="round" opacity=".55"/>
    </svg>
  `),
};

const defaultItems: ExpandableModalItem[] = [
  {
    id: "analytics",
    title: "Analytics report",
    description: "Review weekly product signals.",
    content:
      "Compare activation, retention, and conversion changes before sharing the update with the team.",
    image: coverImages.analytics,
  },
  {
    id: "workflow",
    title: "Workflow template",
    description: "Start from a reusable process.",
    content:
      "Duplicate a structured workflow with handoff steps, owners, and review gates already in place.",
    image: coverImages.workflow,
  },
  {
    id: "release",
    title: "Release checklist",
    description: "Confirm launch readiness.",
    content:
      "Open the checklist, verify the final tasks, and keep the launch status aligned across the workspace.",
    image: coverImages.release,
  },
];

const actionButtonClassName =
  "inline-flex h-7 shrink-0 items-center justify-center rounded-[min(var(--radius-md),12px)] border border-transparent bg-primary bg-clip-padding px-2.5 pt-px text-[0.8rem] leading-[1.4285714286] font-medium whitespace-nowrap text-primary-foreground outline-none transition-colors hover:bg-primary/80 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background";

export function ExpandableModal({
  items = defaultItems,
  value,
  defaultValue = null,
  onValueChange,
  onAction,
  actionLabel = "Open",
  modalLabel = "Item details",
  className,
  listClassName,
  itemClassName,
  ...props
}: ExpandableModalProps) {
  const shouldReduceMotion = useReducedMotion();
  const reactId = React.useId();
  const titleId = `${reactId}-title`;
  const descriptionId = `${reactId}-description`;
  const dialogRef = React.useRef<HTMLDivElement>(null);
  const previouslyFocusedElement = React.useRef<HTMLElement | null>(null);
  const imageDecodePromises = React.useRef(new Map<string, Promise<void>>());
  const decodedImages = React.useRef(new Set<string>());
  const openRequestId = React.useRef(0);
  const isControlled = value !== undefined;
  const [uncontrolledValue, setUncontrolledValue] =
    React.useState<ExpandableModalItem | null>(defaultValue);

  const activeItem = isControlled ? value : uncontrolledValue;

  const setActiveItem = React.useCallback(
    (nextItem: ExpandableModalItem | null) => {
      if (!isControlled) {
        setUncontrolledValue(nextItem);
      }

      onValueChange?.(nextItem);
    },
    [isControlled, onValueChange],
  );

  const decodeImage = React.useCallback((src: string) => {
    return new Promise<void>((resolve) => {
      const image = new Image();

      image.decoding = "sync";
      image.onload = () => {
        if (typeof image.decode === "function") {
          image.decode().then(resolve, resolve);
        } else {
          resolve();
        }
      };
      image.onerror = () => resolve();
      image.src = src;
    });
  }, []);

  const prepareImage = React.useCallback(
    (src: string) => {
      if (decodedImages.current.has(src)) {
        return Promise.resolve();
      }

      const cachedPromise = imageDecodePromises.current.get(src);

      if (cachedPromise) {
        return cachedPromise;
      }

      const promise = decodeImage(src).then(() => {
        decodedImages.current.add(src);
      });

      imageDecodePromises.current.set(src, promise);

      return promise;
    },
    [decodeImage],
  );

  const closeActiveItem = React.useCallback(() => {
    openRequestId.current += 1;
    setActiveItem(null);
  }, [setActiveItem]);

  const handleAction = React.useCallback(
    (item: ExpandableModalItem) => {
      onAction?.(item);
      closeActiveItem();
    },
    [closeActiveItem, onAction],
  );

  const openItem = React.useCallback(
    async (item: ExpandableModalItem) => {
      const requestId = openRequestId.current + 1;

      openRequestId.current = requestId;
      await prepareImage(item.image);

      if (openRequestId.current === requestId) {
        setActiveItem(item);
      }
    },
    [prepareImage, setActiveItem],
  );

  React.useEffect(() => {
    const decodePromises = imageDecodePromises.current;

    items.forEach((item) => {
      void prepareImage(item.image);
    });

    return () => {
      decodePromises.clear();
    };
  }, [items, prepareImage]);

  React.useEffect(() => {
    if (!activeItem) return;

    previouslyFocusedElement.current = document.activeElement as HTMLElement;
    dialogRef.current?.focus();

    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape") {
        closeActiveItem();
        return;
      }

      if (event.key !== "Tab") {
        return;
      }

      const dialog = dialogRef.current;

      if (!dialog) return;

      const focusableElements = Array.from(
        dialog.querySelectorAll<HTMLElement>(
          [
            "button:not([disabled])",
            "a[href]",
            "input:not([disabled])",
            "select:not([disabled])",
            "textarea:not([disabled])",
            "[tabindex]:not([tabindex='-1'])",
          ].join(","),
        ),
      ).filter((element) => element.offsetParent !== null);

      if (focusableElements.length === 0) {
        event.preventDefault();
        dialog.focus();
        return;
      }

      const firstElement = focusableElements[0];
      const lastElement = focusableElements[focusableElements.length - 1];
      const activeElement = document.activeElement as HTMLElement | null;

      if (!activeElement || !dialog.contains(activeElement)) {
        event.preventDefault();
        firstElement.focus();
      } else if (event.shiftKey && activeElement === firstElement) {
        event.preventDefault();
        lastElement.focus();
      } else if (!event.shiftKey && activeElement === lastElement) {
        event.preventDefault();
        firstElement.focus();
      }
    };

    document.addEventListener("keydown", handleKeyDown);
    return () => {
      document.removeEventListener("keydown", handleKeyDown);
      previouslyFocusedElement.current?.focus();
    };
  }, [activeItem, closeActiveItem]);

  const layoutTransition = shouldReduceMotion
    ? { duration: 0 }
    : ({ type: "spring", duration: 0.32, bounce: 0 } as const);
  const fadeTransition = shouldReduceMotion
    ? { duration: 0 }
    : ({ duration: 0.2, ease: [0.215, 0.61, 0.355, 1] } as const);

  return (
    <div
      data-slot="expandable-modal"
      className={cn(
        "relative mx-auto flex w-full items-center justify-center",
        className,
      )}
      {...props}
    >
      <LayoutGroup id={reactId}>
        <AnimatePresence>
          {activeItem ? (
            <motion.div
              key="overlay"
              aria-hidden="true"
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              transition={fadeTransition}
              onClick={closeActiveItem}
              className="absolute inset-0 z-40 bg-background/80 backdrop-blur-sm"
            />
          ) : null}
        </AnimatePresence>

        <AnimatePresence>
          {activeItem ? (
            <div
              className="absolute inset-0 z-50 flex items-center justify-center p-4"
              onMouseDown={(event) => {
                if (event.target === event.currentTarget) {
                  closeActiveItem();
                }
              }}
            >
              <div
                ref={dialogRef}
                role="dialog"
                aria-modal="true"
                aria-label={modalLabel}
                aria-labelledby={titleId}
                aria-describedby={descriptionId}
                tabIndex={-1}
                className="relative w-full max-w-md outline-none"
              >
                <motion.div
                  layoutId={`card-${activeItem.id}`}
                  transition={layoutTransition}
                  className="w-full overflow-hidden rounded-xl border bg-background shadow-lg"
                >
                  <div className="flex items-start gap-3 border-b p-3">
                    <motion.img
                      layoutId={`image-${activeItem.id}`}
                      transition={layoutTransition}
                      src={activeItem.image}
                      alt={activeItem.imageAlt ?? ""}
                      loading="eager"
                      decoding="sync"
                      fetchPriority="high"
                      className="size-14 shrink-0 rounded-lg object-cover"
                    />
                    <div className="flex min-w-0 flex-1 items-start justify-between gap-3">
                      <div className="min-w-0">
                        <motion.h2
                          id={titleId}
                          layoutId={`title-${activeItem.id}`}
                          transition={layoutTransition}
                          className="truncate text-sm font-semibold"
                        >
                          {activeItem.title}
                        </motion.h2>
                        <motion.p
                          id={descriptionId}
                          layoutId={`description-${activeItem.id}`}
                          transition={layoutTransition}
                          className="mt-1 text-sm leading-5 text-muted-foreground"
                        >
                          {activeItem.description}
                        </motion.p>
                      </div>
                      <motion.button
                        type="button"
                        layoutId={`button-${activeItem.id}`}
                        transition={layoutTransition}
                        onClick={() => handleAction(activeItem)}
                        className={actionButtonClassName}
                      >
                        {activeItem.actionLabel ?? actionLabel}
                      </motion.button>
                    </div>
                  </div>
                  <motion.div
                    initial={shouldReduceMotion ? false : { opacity: 0, y: 8 }}
                    animate={{ opacity: 1, y: 0 }}
                    exit={
                      shouldReduceMotion ? undefined : { opacity: 0, y: 4 }
                    }
                    transition={fadeTransition}
                    className="p-4 text-sm leading-6 text-muted-foreground"
                  >
                    {activeItem.content}
                  </motion.div>
                </motion.div>
              </div>
            </div>
          ) : null}
        </AnimatePresence>

        <ul
          data-slot="expandable-modal-list"
          aria-hidden={activeItem ? true : undefined}
          className={cn("flex w-full max-w-md flex-col gap-2", listClassName)}
        >
          {items.map((item) => (
            <li key={item.id}>
              <motion.div
                layoutId={`card-${item.id}`}
                transition={layoutTransition}
                onPointerEnter={() => void prepareImage(item.image)}
                className={cn(
                  "flex w-full items-center gap-3 rounded-lg border bg-background p-3 text-left transition-colors hover:bg-muted/50",
                  itemClassName,
                )}
              >
                <button
                  type="button"
                  onClick={() => void openItem(item)}
                  onFocus={() => void prepareImage(item.image)}
                  className="flex min-w-0 flex-1 items-center gap-3 rounded-md text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
                >
                  <motion.img
                    layoutId={`image-${item.id}`}
                    transition={layoutTransition}
                    src={item.image}
                    alt={item.imageAlt ?? ""}
                    loading="eager"
                    decoding="sync"
                    className="size-14 shrink-0 rounded-lg object-cover"
                  />
                  <span className="min-w-0">
                    <motion.span
                      layoutId={`title-${item.id}`}
                      transition={layoutTransition}
                      className="block truncate text-sm font-semibold"
                    >
                      {item.title}
                    </motion.span>
                    <motion.span
                      layoutId={`description-${item.id}`}
                      transition={layoutTransition}
                      className="mt-1 block truncate text-sm text-muted-foreground"
                    >
                      {item.description}
                    </motion.span>
                  </span>
                </button>
                <motion.button
                  type="button"
                  layoutId={`button-${item.id}`}
                  transition={layoutTransition}
                  onClick={() => void openItem(item)}
                  onFocus={() => void prepareImage(item.image)}
                  aria-label={`${item.actionLabel ?? actionLabel} ${item.title}`}
                  className={actionButtonClassName}
                >
                  {item.actionLabel ?? actionLabel}
                </motion.button>
              </motion.div>
            </li>
          ))}
        </ul>
      </LayoutGroup>
    </div>
  );
}

Installation

pnpm dlx shadcn@latest add https://ui.ericts.com/r/expandable-modal.json