"use client";
import * as React from "react";
import { AnimatePresence, animate, motion, type AnimationPlaybackControls } from "motion/react";
export interface Story {
id: string;
/** Text shown over the bottom of the story and read out with it. */
caption?: string;
/** Accessible description of the visual (the story content is exposed as an image). */
label?: string;
/** Overrides the viewer's `duration` for this story, in ms. */
duration?: number;
/** Content, unless you use `renderStory`. */
content?: React.ReactNode;
}
export interface StoryGroup {
id: string;
/** Person or channel name. */
name: string;
avatar?: React.ReactNode;
/** Small text next to the name, e.g. "2h". */
meta?: string;
stories: Story[];
}
export interface StoryRenderContext {
group: StoryGroup;
index: number;
paused: boolean;
}
export interface StoryViewerProps {
groups: StoryGroup[];
/** Renders a story. Falls back to `story.content`. */
renderStory?: (story: Story, context: StoryRenderContext) => React.ReactNode;
/** Time per story in ms (a story's own `duration` wins). */
duration?: number;
/** true: a row of avatars opens the viewer in a modal <dialog>. false: the viewer renders inline. */
modal?: boolean;
/** Transition between people's story groups. */
transition?: "cube" | "slide" | "fade";
showCaptions?: boolean;
/** After the last story start again (otherwise the modal closes / the inline viewer stops). */
loop?: boolean;
/** Press-and-hold time that pauses instead of navigating, in ms. */
holdDelay?: number;
/** Inline viewer height in px (the width follows a 9:16 frame). */
height?: number;
initialGroup?: number;
onStoryChange?: (groupIndex: number, storyIndex: number) => void;
onOpenChange?: (open: boolean) => void;
/** Accessible name of the viewer (and of the avatar row). */
label?: string;
accent?: string;
className?: string;
}
type Dir = -1 | 0 | 1;
interface Position {
g: number;
s: number;
dir: Dir;
}
function subscribeReducedMotion(cb: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", cb);
return () => mq.removeEventListener("change", cb);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
function subscribeVisibility(cb: () => void) {
document.addEventListener("visibilitychange", cb);
return () => document.removeEventListener("visibilitychange", cb);
}
function usePageHidden() {
return React.useSyncExternalStore(subscribeVisibility, () => document.hidden, () => false);
}
const EASE = [0.32, 0.72, 0, 1] as const;
/** Group-to-group faces. `d` is the direction: 1 forward, -1 back. */
const FACES = {
cube: {
enter: (d: number) => ({ x: `${d * 100}%`, rotateY: d * 90, transformOrigin: d > 0 ? "0% 50%" : "100% 50%", filter: "brightness(0.55)" }),
center: { x: "0%", rotateY: 0, filter: "brightness(1)" },
exit: (d: number) => ({ x: `${-d * 100}%`, rotateY: -d * 90, transformOrigin: d > 0 ? "100% 50%" : "0% 50%", filter: "brightness(0.55)" }),
},
slide: {
enter: (d: number) => ({ x: `${d * 100}%` }),
center: { x: "0%" },
exit: (d: number) => ({ x: `${-d * 100}%` }),
},
fade: {
enter: () => ({ opacity: 0 }),
center: { opacity: 1 },
exit: () => ({ opacity: 0 }),
},
} as const;
function Initials({ name }: { name: string }) {
const letters = name
.split(/\s+/)
.map((w) => w[0])
.join("")
.slice(0, 2)
.toUpperCase();
return <span className="grid size-full place-items-center bg-zinc-200 text-[11px] font-semibold text-zinc-700 dark:bg-zinc-700 dark:text-zinc-100">{letters}</span>;
}
const iconButton =
"grid size-8 place-items-center rounded-full text-white outline-hidden transition-colors hover:bg-white/15 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-solid focus-visible:outline-[var(--sv-accent)] [&_svg]:size-4";
interface StageProps extends Required<Pick<StoryViewerProps, "groups" | "duration" | "transition" | "showCaptions" | "loop" | "holdDelay" | "label">> {
renderStory?: StoryViewerProps["renderStory"];
startGroup: number;
/** Modal only: closes the viewer (Escape, swipe down, ×, end of the last story). */
onDismiss?: () => void;
onStoryChange?: StoryViewerProps["onStoryChange"];
onGroupSeen: (id: string) => void;
frameClassName: string;
frameStyle?: React.CSSProperties;
autoFocus: boolean;
}
/** The 9:16 viewer itself: progress, header, story faces, tap zones and controls. */
function StoryStage({
groups,
renderStory,
duration,
transition,
showCaptions,
loop,
holdDelay,
label,
startGroup,
onDismiss,
onStoryChange,
onGroupSeen,
frameClassName,
frameStyle,
autoFocus,
}: StageProps) {
const reduceMotion = usePrefersReducedMotion();
const pageHidden = usePageHidden();
const [pos, setPos] = React.useState<Position>({ g: Math.max(0, Math.min(groups.length - 1, startGroup)), s: 0, dir: 0 });
const [paused, setPaused] = React.useState(false);
const [held, setHeld] = React.useState(false);
const [offscreen, setOffscreen] = React.useState(false);
const [ended, setEnded] = React.useState(false);
// Bumped to restart the current story from zero (e.g. "previous" on the very first story).
const [restart, setRestart] = React.useState(0);
const frameRef = React.useRef<HTMLDivElement>(null);
// Progress fills keyed "groupId:index" (the outgoing face keeps its own until it unmounts).
const fills = React.useRef(new Map<string, HTMLSpanElement>());
const elapsed = React.useRef(0);
const press = React.useRef({ timer: 0, x: 0, y: 0, id: -1, held: false, dragging: false, suppressClick: false });
const dragAnim = React.useRef<AnimationPlaybackControls | null>(null);
const group = groups[pos.g];
const stories = group?.stories ?? [];
const story = stories[pos.s];
const storyDuration = Math.max(500, story?.duration ?? duration);
const playing = !paused && !held && !pageHidden && !offscreen && !ended && !!story;
const go = (next: Position | "end") => {
if (next === "end") {
if (group) onGroupSeen(group.id);
if (loop) setPos({ g: 0, s: 0, dir: groups.length > 1 ? 1 : 0 });
else if (onDismiss) onDismiss();
else setEnded(true);
return;
}
if (next.g !== pos.g && group && next.dir > 0) onGroupSeen(group.id);
setEnded(false);
setPos(next);
};
const next = () => {
if (pos.s < stories.length - 1) go({ g: pos.g, s: pos.s + 1, dir: 0 });
else if (pos.g < groups.length - 1) go({ g: pos.g + 1, s: 0, dir: 1 });
else go("end");
};
const prev = () => {
if (ended) {
setEnded(false);
setRestart((n) => n + 1);
} else if (pos.s > 0) go({ g: pos.g, s: pos.s - 1, dir: 0 });
else if (pos.g > 0) go({ g: pos.g - 1, s: 0, dir: -1 });
else setRestart((n) => n + 1);
};
const replay = () => {
setEnded(false);
setPaused(false);
setPos({ g: 0, s: 0, dir: pos.g === 0 ? 0 : -1 });
setRestart((n) => n + 1);
};
// Latest navigation for the rAF loop.
const nextRef = React.useRef(next);
React.useEffect(() => {
nextRef.current = next;
});
React.useEffect(() => {
onStoryChange?.(pos.g, pos.s);
}, [pos.g, pos.s, onStoryChange]);
// New story: reset the clock and paint the segments (done, current, upcoming).
const groupId = group?.id;
const storyCount = stories.length;
React.useLayoutEffect(() => {
elapsed.current = 0;
for (let i = 0; i < storyCount; i++) {
const el = fills.current.get(`${groupId}:${i}`);
if (el) el.style.transform = `scaleX(${i < pos.s || ended ? 1 : 0})`;
}
}, [groupId, storyCount, pos.s, restart, ended]);
// The clock: a rAF loop that only runs while playing and writes the fill directly (no re-renders).
React.useEffect(() => {
if (!playing) return;
let frame = 0;
let last = performance.now();
const tick = (now: number) => {
elapsed.current += Math.min(100, now - last);
last = now;
const p = Math.min(1, elapsed.current / storyDuration);
const fill = fills.current.get(`${groupId}:${pos.s}`);
if (fill) fill.style.transform = `scaleX(${p.toFixed(4)})`;
if (p >= 1) {
nextRef.current();
return;
}
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [playing, groupId, pos.s, storyDuration, restart]);
// Pause while scrolled out of view.
React.useEffect(() => {
const el = frameRef.current;
if (!el || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver(([entry]) => setOffscreen(!entry.isIntersecting), { threshold: 0.25 });
io.observe(el);
return () => io.disconnect();
}, []);
React.useEffect(() => {
if (autoFocus) frameRef.current?.focus({ preventScroll: true });
}, [autoFocus]);
React.useEffect(
() => () => {
window.clearTimeout(press.current.timer);
dragAnim.current?.stop();
},
[],
);
const endPress = () => {
window.clearTimeout(press.current.timer);
press.current.id = -1;
if (press.current.held) {
press.current.held = false;
press.current.suppressClick = true;
setHeld(false);
}
};
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0 || (event.target as HTMLElement).closest("[data-sv-control]")) return;
const p = press.current;
dragAnim.current?.stop();
p.id = event.pointerId;
p.x = event.clientX;
p.y = event.clientY;
p.held = false;
p.dragging = false;
p.suppressClick = false;
window.clearTimeout(p.timer);
// Press and hold pauses (and hides the chrome) until release.
p.timer = window.setTimeout(() => {
p.held = true;
setHeld(true);
}, holdDelay);
};
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const p = press.current;
if (p.id !== event.pointerId) return;
const dx = event.clientX - p.x;
const dy = event.clientY - p.y;
if (!p.dragging) {
if (Math.hypot(dx, dy) < 10) return;
window.clearTimeout(p.timer);
if (!onDismiss || dy <= 0 || Math.abs(dy) < Math.abs(dx) * 1.2) {
// Not a swipe down: just cancel the tap.
p.suppressClick = true;
return;
}
p.dragging = true;
p.suppressClick = true;
event.currentTarget.setPointerCapture(event.pointerId);
if (!p.held) {
p.held = true;
setHeld(true);
}
}
const frame = frameRef.current;
if (frame) {
const y = Math.max(0, dy);
frame.style.transform = `translateY(${y.toFixed(1)}px) scale(${(1 - Math.min(0.12, y / 2400)).toFixed(4)})`;
}
};
const onPointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
const p = press.current;
if (p.id !== event.pointerId) return;
if (p.dragging) {
p.dragging = false;
const dy = event.clientY - p.y;
const frame = frameRef.current;
if (dy > 110 && onDismiss) {
endPress();
onDismiss();
return;
}
if (frame) {
dragAnim.current = reduceMotion
? null
: animate(frame, { transform: "translateY(0px) scale(1)" }, { type: "spring", stiffness: 420, damping: 36 });
if (reduceMotion) frame.style.transform = "";
}
}
endPress();
};
const tap = (direction: 1 | -1) => {
if (press.current.suppressClick) {
press.current.suppressClick = false;
return;
}
if (direction > 0) next();
else prev();
};
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.altKey || event.ctrlKey || event.metaKey) return;
if ((event.target as HTMLElement).closest("button") && (event.key === " " || event.key === "Enter")) return;
if (event.key === "ArrowRight") next();
else if (event.key === "ArrowLeft") prev();
else if (event.key === " " || event.key === "k" || event.key === "K") {
if (ended) replay();
else setPaused((v) => !v);
} else return;
event.preventDefault();
};
if (!group || !story) return null;
const variants = reduceMotion ? FACES.fade : FACES[transition] ?? FACES.cube;
const faceTransition = reduceMotion || transition === "fade" ? { duration: 0.22 } : { duration: 0.5, ease: EASE };
const position = `${group.name}, story ${pos.s + 1} of ${stories.length}`;
return (
<div
ref={frameRef}
role="region"
aria-roledescription="stories"
aria-label={label}
tabIndex={0}
data-held={held || undefined}
onKeyDown={onKeyDown}
className={`group/sv relative isolate aspect-[9/16] select-none overflow-hidden rounded-2xl bg-zinc-950 text-white outline-hidden [-webkit-touch-callout:none] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--sv-accent)] ${frameClassName}`}
style={frameStyle}
>
{/* Story faces. Group changes animate (cube / slide / fade); stories within a group cut. */}
<div className="absolute inset-0 [perspective:1100px]" onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp} onContextMenu={(event) => event.preventDefault()} style={{ touchAction: onDismiss ? "none" : "pan-y" }}>
<AnimatePresence initial={false} custom={pos.dir}>
<motion.div
key={group.id}
custom={pos.dir}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={faceTransition}
className="absolute inset-0 overflow-hidden [backface-visibility:hidden]"
>
<div className="absolute inset-0" role={story.label ? "img" : undefined} aria-label={story.label}>
{renderStory ? renderStory(story, { group, index: pos.s, paused: !playing }) : story.content}
</div>
{/* Scrims keep the chrome and caption readable on any content. */}
<div aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 h-28 bg-gradient-to-b from-black/55 to-transparent transition-opacity duration-200 group-data-[held]/sv:opacity-0" />
{showCaptions && story.caption ? (
<div className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 via-black/35 to-transparent px-4 pb-5 pt-16 transition-opacity duration-200 group-data-[held]/sv:opacity-0">
<p className="text-[14px] font-medium leading-snug tracking-[-0.01em] text-white [text-wrap:pretty]">{story.caption}</p>
</div>
) : null}
<div className="pointer-events-none absolute inset-x-0 top-0 px-3 pt-3 transition-opacity duration-200 @container group-data-[held]/sv:opacity-0">
<div aria-hidden="true" className="flex gap-[3px]">
{stories.map((st, i) => (
<span key={st.id} className="relative h-[2px] flex-1 overflow-hidden rounded-full bg-white/30">
<span
ref={(el) => {
if (!el) return;
const key = `${group.id}:${i}`;
const map = fills.current;
map.set(key, el);
return () => {
if (map.get(key) === el) map.delete(key);
};
}}
className="absolute inset-0 origin-left rounded-full bg-white"
style={{ transform: i < pos.s ? "scaleX(1)" : "scaleX(0)" }}
/>
</span>
))}
</div>
<div className={`mt-2.5 flex items-center gap-2 ${onDismiss ? "pr-[76px]" : "pr-9"}`}>
<span aria-hidden="true" className="size-7 shrink-0 overflow-hidden rounded-full ring-1 ring-white/40">
{group.avatar ?? <Initials name={group.name} />}
</span>
<span className="truncate text-[13px] font-semibold tracking-[-0.01em] [text-shadow:0_1px_2px_rgb(0_0_0/0.4)]">{group.name}</span>
{group.meta ? <span className="shrink-0 font-mono text-[10px] uppercase tracking-[0.14em] text-white/70 @max-[200px]:hidden">{group.meta}</span> : null}
</div>
</div>
</motion.div>
</AnimatePresence>
{/* Tap zones: left third goes back, the rest goes forward. Real buttons for assistive tech. */}
<button type="button" tabIndex={-1} aria-label="Previous story" onClick={() => tap(-1)} className="absolute inset-y-0 left-0 z-[1] w-1/3 cursor-w-resize outline-hidden" />
<button type="button" tabIndex={-1} aria-label="Next story" onClick={() => tap(1)} className="absolute inset-y-0 right-0 z-[1] w-2/3 cursor-e-resize outline-hidden" />
</div>
{/* Controls stay put while faces turn, so focus is never lost mid-transition. */}
<div data-sv-control="" className="absolute right-2 top-[18px] z-[2] flex items-center transition-opacity duration-200 group-data-[held]/sv:opacity-0">
<button type="button" onClick={() => (ended ? replay() : setPaused((v) => !v))} aria-label={ended ? "Replay stories" : paused ? "Play stories" : "Pause stories"} className={iconButton}>
{ended ? (
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M3 8a5 5 0 1 0 1.6-3.7M3 2.5v2.8h2.8" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : paused ? (
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M5 3.2v9.6a.6.6 0 0 0 .9.5l7.4-4.8a.6.6 0 0 0 0-1L5.9 2.7a.6.6 0 0 0-.9.5Z" />
</svg>
) : (
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<rect x="4" y="3" width="2.6" height="10" rx="0.8" />
<rect x="9.4" y="3" width="2.6" height="10" rx="0.8" />
</svg>
)}
</button>
{onDismiss ? (
<button type="button" onClick={onDismiss} aria-label="Close stories" className={iconButton}>
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
</button>
) : null}
</div>
{ended ? (
<div data-sv-control="" className="absolute inset-0 z-[3] grid place-items-center bg-black/45">
<button
type="button"
onClick={replay}
className="inline-flex h-9 items-center gap-2 rounded-full bg-white px-4 text-[13px] font-medium text-zinc-950 outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--sv-accent)]"
>
Replay
</button>
</div>
) : null}
{paused && !ended ? (
<span aria-hidden="true" className="pointer-events-none absolute left-1/2 top-1/2 z-[2] -translate-x-1/2 -translate-y-1/2 rounded-full bg-black/45 px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-white/90 backdrop-blur-sm">
Paused
</span>
) : null}
{/* Announcements are silenced during autoplay and spoken when paused or navigating by hand. */}
<p className="sr-only" aria-live={playing ? "off" : "polite"} aria-atomic="true">
{[position, story.caption, paused ? "Paused" : ""]
.filter(Boolean)
.map((part) => (/[.!?]$/.test(part as string) ? part : `${part}.`))
.join(" ")}
</p>
</div>
);
}
/**
* A stories viewer: segmented progress bars, auto-advance per story, tap right / left to move,
* press-and-hold to pause, a cube turn between people, captions and a visible pause button.
* It pauses when the tab is hidden or the viewer scrolls away. In modal mode a row of avatars opens
* it in a native <dialog>, where swipe down and Escape close it. Content comes from a render prop.
*/
export function StoryViewer({
groups,
renderStory,
duration = 5000,
modal = true,
transition = "cube",
showCaptions = true,
loop = false,
holdDelay = 200,
height = 480,
initialGroup = 0,
onStoryChange,
onOpenChange,
label = "Stories",
accent = "#FF7448",
className = "",
}: StoryViewerProps) {
const reduceMotion = usePrefersReducedMotion();
const [open, setOpen] = React.useState(false);
const [startGroup, setStartGroup] = React.useState(initialGroup);
const [session, setSession] = React.useState(0);
const [seen, setSeen] = React.useState<ReadonlySet<string>>(() => new Set());
const dialogRef = React.useRef<HTMLDialogElement>(null);
const panelRef = React.useRef<HTMLDivElement>(null);
const backdropRef = React.useRef<HTMLDivElement>(null);
const triggers = React.useRef<(HTMLButtonElement | null)[]>([]);
const returnTo = React.useRef(0);
const closing = React.useRef(false);
const anims = React.useRef<AnimationPlaybackControls[]>([]);
const markSeen = React.useCallback((id: string) => {
setSeen((prev) => (prev.has(id) ? prev : new Set(prev).add(id)));
}, []);
const openAt = (index: number) => {
returnTo.current = index;
setStartGroup(index);
setSession((n) => n + 1);
setOpen(true);
onOpenChange?.(true);
};
const finishClose = () => {
anims.current.forEach((a) => a.stop());
anims.current = [];
closing.current = false;
if (dialogRef.current?.open) dialogRef.current.close();
};
const requestClose = () => {
const panel = panelRef.current;
const backdrop = backdropRef.current;
if (closing.current || !panel || !backdrop) return;
closing.current = true;
anims.current.forEach((a) => a.stop());
const d = reduceMotion ? 0.14 : 0.2;
anims.current = [animate(backdrop, { opacity: 0 }, { duration: d }), animate(panel, reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.94 }, { duration: d, ease: "easeIn" })];
void Promise.all(anims.current.map((a) => a.finished)).then(() => {
if (closing.current) finishClose();
});
};
// Open: showModal (top layer, inert page) and fade / scale in.
React.useLayoutEffect(() => {
const dialog = dialogRef.current;
const panel = panelRef.current;
const backdrop = backdropRef.current;
if (!open || !dialog || !panel || !backdrop || dialog.open) return;
dialog.showModal();
anims.current = [
animate(backdrop, { opacity: [0, 1] }, { duration: 0.2 }),
animate(panel, reduceMotion ? { opacity: [0, 1] } : { opacity: [0, 1], scale: [0.94, 1] }, reduceMotion ? { duration: 0.16 } : { type: "spring", stiffness: 380, damping: 32 }),
];
}, [open, reduceMotion]);
React.useEffect(
() => () => {
anims.current.forEach((a) => a.stop());
if (dialogRef.current?.open) dialogRef.current.close();
},
[],
);
const stageProps = {
groups,
renderStory,
duration,
transition,
showCaptions,
loop,
holdDelay,
label,
onStoryChange,
onGroupSeen: markSeen,
};
const style = { ["--sv-accent" as string]: accent } as React.CSSProperties;
if (!modal) {
return (
<div className={`inline-block ${className}`} style={style}>
<StoryStage {...stageProps} startGroup={initialGroup} frameClassName="" frameStyle={{ height }} autoFocus={false} />
</div>
);
}
return (
<div className={className} style={style}>
<ul aria-label={label} className="m-0 flex list-none gap-3 overflow-x-auto p-1 [scrollbar-width:none]">
{groups.map((g, i) => {
const done = seen.has(g.id);
return (
<li key={g.id} className="shrink-0">
<button
ref={(el) => {
triggers.current[i] = el;
}}
type="button"
aria-haspopup="dialog"
aria-label={`${g.name}, ${g.stories.length} ${g.stories.length === 1 ? "story" : "stories"}${done ? ", seen" : ""}`}
onClick={() => openAt(i)}
className="group/av flex w-[72px] flex-col items-center gap-1.5 rounded-xl p-1 outline-hidden focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-[var(--sv-accent)]"
>
<span
aria-hidden="true"
className={`grid size-[60px] place-items-center rounded-full p-[2.5px] transition-transform duration-200 group-active/av:scale-95 ${done ? "bg-zinc-900/15 dark:bg-white/20" : "bg-[conic-gradient(from_200deg,var(--sv-accent),color-mix(in_oklab,var(--sv-accent)_45%,#ffd36e),var(--sv-accent))]"}`}
>
<span className="grid size-full place-items-center rounded-full bg-[#F5F3EE] p-[2px] dark:bg-[#131619]">
<span className="size-full overflow-hidden rounded-full">{g.avatar ?? <Initials name={g.name} />}</span>
</span>
</span>
<span className={`w-full truncate text-center text-[11.5px] tracking-[-0.005em] ${done ? "text-zinc-500 dark:text-zinc-400" : "font-medium text-zinc-800 dark:text-zinc-100"}`}>{g.name}</span>
</button>
</li>
);
})}
</ul>
<dialog
ref={dialogRef}
aria-label={label}
onCancel={(event) => {
event.preventDefault();
requestClose();
}}
onClose={() => {
closing.current = false;
setOpen(false);
onOpenChange?.(false);
triggers.current[returnTo.current]?.focus({ preventScroll: true });
}}
className="fixed inset-0 m-0 h-dvh max-h-none w-screen max-w-none overflow-hidden border-0 bg-transparent p-0 text-left backdrop:bg-transparent"
style={style}
>
<div ref={backdropRef} aria-hidden="true" className="absolute inset-0 bg-[#0b0d0f]/92" onClick={requestClose} />
<div ref={panelRef} className="pointer-events-none relative flex h-full w-full items-center justify-center p-3 sm:p-6">
{open ? (
<StoryStage
key={session}
{...stageProps}
startGroup={startGroup}
onDismiss={requestClose}
frameClassName="pointer-events-auto max-w-full shadow-[0_40px_120px_-30px_rgb(0_0_0/0.8)]"
frameStyle={{ height: "min(100%, 760px)" }}
autoFocus
/>
) : null}
</div>
</dialog>
</div>
);
}