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