Expandable Dialog
A shared-layout dialog that expands list items into an accessible detail view.
overlayshared-layout
"use client";
import * as React from "react";
import {
AnimatePresence,
LayoutGroup,
motion,
useReducedMotion,
} from "motion/react";
import { cn } from "@/lib/utils";
export type ExpandableDialogItem = {
id: string;
title: string;
description: string;
content: React.ReactNode;
image: string;
imageAlt?: string;
actionLabel?: string;
};
export type ExpandableDialogProps = Omit<
React.ComponentProps<"div">,
"children" | "defaultValue" | "onChange" | "value"
> & {
items: readonly ExpandableDialogItem[];
/** Id of the expanded item, or null when collapsed. */
value?: string | null;
defaultValue?: string | null;
/** Receives the expanded item's id, plus the item itself for convenience. */
onValueChange?: (
id: string | null,
item: ExpandableDialogItem | null,
) => void;
onAction?: (item: ExpandableDialogItem) => void;
actionLabel?: string;
modalLabel?: string;
listClassName?: string;
itemClassName?: string;
};
/** How long a click waits for image decode before opening regardless. */
const DECODE_BUDGET_MS = 200;
function wait(duration: number) {
return new Promise<void>((resolve) => {
window.setTimeout(resolve, duration);
});
}
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 ExpandableDialog({
items,
value,
defaultValue = null,
onValueChange,
onAction,
actionLabel = "Open",
modalLabel,
className,
listClassName,
itemClassName,
...props
}: ExpandableDialogProps) {
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<
string | null
>(defaultValue);
const activeItemValue = isControlled ? value : uncontrolledValue;
const activeItem =
items.find((item) => item.id === activeItemValue) ?? null;
const setActiveItem = React.useCallback(
(nextItem: ExpandableDialogItem | null) => {
if (!isControlled) {
setUncontrolledValue(nextItem?.id ?? null);
}
onValueChange?.(nextItem?.id ?? null, 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: ExpandableDialogItem) => {
onAction?.(item);
closeActiveItem();
},
[closeActiveItem, onAction],
);
const openItem = React.useCallback(
async (item: ExpandableDialogItem) => {
const requestId = openRequestId.current + 1;
openRequestId.current = requestId;
// Decoding first keeps the open animation from stuttering, but a slow
// remote image must never make the click feel dead: past this budget the
// modal opens anyway and the image lands when it lands.
await Promise.race([prepareImage(item.image), wait(DECODE_BUDGET_MS)]);
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]);
// Read via a ref inside the focus-trap effect: with an inline controlled
// `onValueChange`, `closeActiveItem`'s identity changes every parent render,
// and keying the effect on it would re-run the trap and yank focus back to
// the dialog root while the user is tabbing through its controls.
const closeActiveItemRef = React.useRef(closeActiveItem);
React.useEffect(() => {
closeActiveItemRef.current = closeActiveItem;
});
const activeItemId = activeItem?.id ?? null;
React.useEffect(() => {
if (activeItemId === null) return;
previouslyFocusedElement.current = document.activeElement as HTMLElement;
dialogRef.current?.focus();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closeActiveItemRef.current();
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();
};
}, [activeItemId]);
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-dialog"
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}
// Dismissal is owned by the z-50 dialog container's onMouseDown;
// this layer sits beneath it and can never receive the click.
className="pointer-events-none 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"
// The visible title labels the dialog by default; an explicit
// `modalLabel` takes over (aria-labelledby would silently win
// if both were set).
aria-label={modalLabel}
aria-labelledby={modalLabel ? undefined : 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-dialog-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>
);
}