{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "activity-timeline",
  "type": "registry:ui",
  "title": "Activity Timeline",
  "description": "A vertical timeline whose line fills to the current step, with staggered reveal and collapsible notes.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/activity-timeline.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { motion, useInView } from \"motion/react\";\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nexport type TimelineStatus = \"done\" | \"active\" | \"pending\" | \"error\";\n\nexport interface TimelineItem {\n  id: string;\n  title: React.ReactNode;\n  description?: React.ReactNode;\n  /** Display text for the time, e.g. \"09:41\" or \"12s\". */\n  time?: string;\n  /** Machine-readable value for <time dateTime> (\"2026-10-13T09:41\", \"PT12S\"). */\n  dateTime?: string;\n  status: TimelineStatus;\n  /** Replaces the status glyph inside the marker. */\n  icon?: React.ReactNode;\n}\n\nexport interface ActivityTimelineProps extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\"> {\n  items: TimelineItem[];\n  /** Accessible name of the list. */\n  label?: string;\n  /** Delay between items as they reveal, in ms. */\n  stagger?: number;\n  /** Reveal items and fill the line the first time the list scrolls into view. */\n  revealOnView?: boolean;\n  /** Clamp descriptions to this many lines with a Show more toggle. 0 never clamps. */\n  collapsedLines?: number;\n  density?: \"comfortable\" | \"compact\";\n  showTime?: boolean;\n  /** Line fill, active marker and focus colour (any CSS colour). */\n  accent?: string;\n  /** Text conveying each status to assistive tech (and on the active/error badges). */\n  statusLabels?: Partial<Record<TimelineStatus, string>>;\n}\n\nconst DEFAULT_STATUS_LABELS: Record<TimelineStatus, string> = {\n  done: \"Completed\",\n  active: \"In progress\",\n  pending: \"Pending\",\n  error: \"Failed\",\n};\n\nfunction subscribeVisibility(callback: () => void) {\n  document.addEventListener(\"visibilitychange\", callback);\n  return () => document.removeEventListener(\"visibilitychange\", callback);\n}\n\n/** Description that clamps to N lines and offers a toggle only when it actually overflows. */\nfunction Description({ id, lines, children }: { id: string; lines: number; children: React.ReactNode }) {\n  const ref = React.useRef<HTMLDivElement>(null);\n  const [expanded, setExpanded] = React.useState(false);\n  const [overflowing, setOverflowing] = React.useState(false);\n\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!el || lines <= 0 || expanded) return;\n    // Measured in an observer callback, so it follows container and font changes.\n    const ro = new ResizeObserver(() => setOverflowing(el.scrollHeight > el.clientHeight + 1));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, [lines, expanded]);\n\n  const clamped = lines > 0 && !expanded;\n  return (\n    <>\n      <div\n        ref={ref}\n        id={id}\n        className=\"mt-1 text-[13px] leading-relaxed text-zinc-600 dark:text-zinc-400\"\n        style={clamped ? { display: \"-webkit-box\", WebkitBoxOrient: \"vertical\", WebkitLineClamp: lines, overflow: \"hidden\" } : undefined}\n      >\n        {children}\n      </div>\n      {lines > 0 && (overflowing || expanded) && (\n        <button\n          type=\"button\"\n          aria-expanded={expanded}\n          aria-controls={id}\n          onClick={() => setExpanded((e) => !e)}\n          className=\"mt-1 rounded font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 underline decoration-zinc-900/20 underline-offset-4 outline-none transition-colors hover:text-zinc-900 focus-visible:ring-2 focus-visible:ring-[var(--at-accent)] dark:text-zinc-400 dark:decoration-white/20 dark:hover:text-white\"\n        >\n          {expanded ? \"Show less\" : \"Show more\"}\n        </button>\n      )}\n    </>\n  );\n}\n\nfunction StatusGlyph({ status }: { status: TimelineStatus }) {\n  if (status === \"done\")\n    return (\n      <svg viewBox=\"0 0 12 12\" className=\"size-3\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.9\" strokeLinecap=\"round\" strokeLinejoin=\"round\">\n        <path d=\"M2.5 6.2 5 8.5l4.5-5\" />\n      </svg>\n    );\n  if (status === \"error\")\n    return (\n      <svg viewBox=\"0 0 12 12\" className=\"size-3\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.8\" strokeLinecap=\"round\">\n        <path d=\"M3.5 3.5l5 5M8.5 3.5l-5 5\" />\n      </svg>\n    );\n  if (status === \"active\") return <span className=\"size-2 rounded-full bg-[var(--at-accent)]\" />;\n  return <span className=\"size-1.5 rounded-full bg-zinc-900/20 dark:bg-white/25\" />;\n}\n\n/**\n * A vertical activity timeline as a semantic ordered list. The connecting line fills segment by\n * segment up to the current item, items reveal with a stagger the first time they scroll into\n * view, the active marker pulses while visible, and long descriptions collapse.\n */\nexport function ActivityTimeline({\n  items,\n  label = \"Activity\",\n  stagger = 80,\n  revealOnView = true,\n  collapsedLines = 2,\n  density = \"comfortable\",\n  showTime = true,\n  accent = \"#FF7448\",\n  statusLabels,\n  className = \"\",\n  style,\n  ...props\n}: ActivityTimelineProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const uid = React.useId();\n  const listRef = React.useRef<HTMLOListElement>(null);\n  const seen = useInView(listRef, { once: true, amount: 0.15 });\n  const onScreen = useInView(listRef);\n  const hidden = React.useSyncExternalStore(subscribeVisibility, () => document.hidden, () => false);\n  const labels = { ...DEFAULT_STATUS_LABELS, ...statusLabels };\n\n  const animateIn = revealOnView && !reduceMotion;\n  const shown = !revealOnView || seen;\n\n  // The line fills up to the active (or failed) item; with neither, up to the last finished one.\n  let reach = items.findIndex((i) => i.status === \"active\" || i.status === \"error\");\n  if (reach === -1) {\n    items.forEach((it, i) => {\n      if (it.status === \"done\") reach = i;\n    });\n  }\n\n  // The first reveal fills segments one after another; later changes animate immediately.\n  const [introDone, setIntroDone] = React.useState(false);\n  React.useEffect(() => {\n    if (!shown || introDone) return;\n    const t = setTimeout(() => setIntroDone(true), 300 + items.length * Math.max(stagger, 60) + 400);\n    return () => clearTimeout(t);\n  }, [shown, introDone, items.length, stagger]);\n\n  const compact = density === \"compact\";\n  const marker = compact ? 22 : 28;\n  const pulse = onScreen && !hidden && !reduceMotion;\n\n  return (\n    <div className={`w-full ${className}`} style={{ [\"--at-accent\" as string]: accent, ...style }} {...props}>\n      <ol ref={listRef} aria-label={label} className=\"relative\">\n        {items.map((item, i) => {\n          const isLast = i === items.length - 1;\n          const segmentFilled = shown && i < reach;\n          const descId = `${uid}-desc-${item.id}`;\n          const toneMarker =\n            item.status === \"done\"\n              ? \"border-transparent bg-[var(--at-accent)] text-white dark:text-zinc-950\"\n              : item.status === \"active\"\n                ? \"border-[var(--at-accent)] bg-white dark:bg-zinc-950\"\n                : item.status === \"error\"\n                  ? \"border-transparent bg-rose-500 text-white dark:bg-rose-400 dark:text-zinc-950\"\n                  : \"border-zinc-900/15 bg-white text-zinc-400 dark:border-white/15 dark:bg-zinc-950 dark:text-zinc-500\";\n          return (\n            <motion.li\n              key={item.id}\n              aria-current={item.status === \"active\" ? \"step\" : undefined}\n              initial={animateIn ? { opacity: 0, y: 10 } : false}\n              animate={shown ? { opacity: 1, y: 0 } : { opacity: 0, y: 10 }}\n              transition={{ duration: 0.45, ease: [0.2, 0.7, 0.2, 1], delay: animateIn && !introDone ? (i * stagger) / 1000 : 0 }}\n              className={`relative grid grid-cols-[auto_minmax(0,1fr)] ${compact ? \"gap-x-3\" : \"gap-x-4\"} ${isLast ? \"\" : compact ? \"pb-4\" : \"pb-7\"}`}\n            >\n              {/* Connector to the next item: a hairline track with an accent fill. */}\n              {!isLast && (\n                <span\n                  aria-hidden=\"true\"\n                  className=\"absolute w-px overflow-hidden bg-zinc-900/10 dark:bg-white/10\"\n                  style={{ left: marker / 2 - 0.5, top: marker + 4, bottom: 4 }}\n                >\n                  <motion.span\n                    className=\"absolute inset-0 origin-top bg-[var(--at-accent)]\"\n                    initial={false}\n                    animate={{ scaleY: segmentFilled ? 1 : 0 }}\n                    transition={\n                      reduceMotion\n                        ? { duration: 0 }\n                        : { duration: 0.5, ease: [0.4, 0, 0.2, 1], delay: introDone ? 0 : 0.25 + (i * Math.max(stagger, 60)) / 1000 }\n                    }\n                  />\n                </span>\n              )}\n\n              <span\n                aria-hidden=\"true\"\n                className={`relative z-[1] grid shrink-0 place-items-center rounded-full border transition-colors duration-300 motion-reduce:transition-none ${toneMarker}`}\n                style={{ width: marker, height: marker }}\n              >\n                {item.status === \"active\" && (\n                  <span\n                    className={`absolute -inset-px rounded-full border border-[var(--at-accent)] ${pulse ? \"animate-ping [animation-duration:1.6s]\" : \"opacity-0\"}`}\n                  />\n                )}\n                {item.icon ? <span className=\"grid size-3.5 place-items-center [&>svg]:size-full\">{item.icon}</span> : <StatusGlyph status={item.status} />}\n              </span>\n\n              <div className={`min-w-0 ${compact ? \"pt-0.5\" : \"pt-1\"}`}>\n                <div className=\"flex flex-wrap items-baseline justify-between gap-x-3 gap-y-0.5\">\n                  <p\n                    className={`min-w-0 font-medium tracking-[-0.01em] ${compact ? \"text-[13.5px]\" : \"text-[14.5px]\"} ${\n                      item.status === \"pending\" ? \"text-zinc-500 dark:text-zinc-400\" : \"text-zinc-950 dark:text-zinc-50\"\n                    }`}\n                  >\n                    <span className=\"sr-only\">{labels[item.status]}: </span>\n                    {item.title}\n                    {(item.status === \"active\" || item.status === \"error\") && (\n                      <span\n                        aria-hidden=\"true\"\n                        className={`ml-2 inline-block translate-y-[-1px] rounded-full px-1.5 py-px align-middle font-mono text-[9px] uppercase tracking-[0.14em] ${\n                          item.status === \"error\"\n                            ? \"bg-rose-500/10 text-rose-600 dark:text-rose-400\"\n                            : \"bg-[color-mix(in_srgb,var(--at-accent)_14%,transparent)] text-zinc-800 dark:text-zinc-100\"\n                        }`}\n                      >\n                        {labels[item.status]}\n                      </span>\n                    )}\n                  </p>\n                  {showTime && item.time && (\n                    <span className=\"shrink-0 font-mono text-[10px] tabular-nums uppercase tracking-[0.12em] text-zinc-500 dark:text-zinc-400\">\n                      {item.dateTime ? <time dateTime={item.dateTime}>{item.time}</time> : item.time}\n                    </span>\n                  )}\n                </div>\n                {item.description && (\n                  <Description id={descId} lines={collapsedLines}>\n                    {item.description}\n                  </Description>\n                )}\n              </div>\n            </motion.li>\n          );\n        })}\n      </ol>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "data"
  ],
  "docs": "Documentation: https://wancraft.my/components/activity-timeline"
}
