{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "timeline",
  "title": "Timeline",
  "description": "Events in order with time, title, detail and a status that keeps its meaning under any accent, down a rail, on two sides, as one-liners or across a row.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://miseui.junsu4621.workers.dev/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/timeline.tsx",
      "content": "import type * as React from \"react\"\r\nimport { DEFAULT_TIMELINE_LABELS, LIST, type TimelineEvent, type TimelineLabels, type TimelineVariant } from \"@/registry/miseui/ui/timeline-lists\"\r\nimport type { TimelineStatus } from \"@/registry/miseui/ui/timeline-parts\"\r\nimport { cn } from \"@/registry/miseui/lib/utils\"\r\n\r\ntype TimelineEffect = \"draw\"\r\n\r\ntype TimelineProps = Omit<React.ComponentProps<\"div\">, \"children\" | \"aria-label\"> & {\r\n  /** In the order they should read. */\r\n  events: readonly TimelineEvent[]\r\n  /** `vertical` a rail with details, `alternate` two sides on a wide container and one on a narrow one, `compact` one line per event, `horizontal` a row that scrolls inside itself. */\r\n  variant?: TimelineVariant\r\n  /** `draw` grows each stretch of the line as it scrolls into view. */\r\n  effect?: TimelineEffect\r\n  /** Names the list; `horizontal` needs it, because its scroller is a focus stop. */\r\n  \"aria-label\"?: string\r\n  labels?: Partial<TimelineLabels>\r\n}\r\n\r\nfunction Timeline({ events, variant = \"vertical\", effect, \"aria-label\": label, labels, className, ...props }: TimelineProps) {\r\n  const List = LIST[variant]\r\n  return (\r\n    <div\r\n      data-slot=\"timeline\"\r\n      data-variant={variant}\r\n      data-effect={effect}\r\n      // The column template is what lets a scrolling child shrink instead of pushing the root to its full length.\r\n      className={cn(\"@container grid w-full min-w-0 grid-cols-[minmax(0,1fr)]\", className)}\r\n      {...props}\r\n    >\r\n      <List events={events} draw={effect === \"draw\"} labels={{ ...DEFAULT_TIMELINE_LABELS, ...labels }} label={label} />\r\n    </div>\r\n  )\r\n}\r\n\r\nexport { Timeline, type TimelineProps, type TimelineEvent, type TimelineVariant, type TimelineEffect, type TimelineStatus, type TimelineLabels }\r\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/timeline-lists.tsx",
      "content": "import type * as React from \"react\"\r\nimport { STATUS_TEXT_CLASS, TimelineConnector, TimelineDot, TimelineMarker, type TimelineStatus } from \"@/registry/miseui/ui/timeline-parts\"\r\nimport { cn } from \"@/registry/miseui/lib/utils\"\r\n\r\ntype TimelineVariant = \"vertical\" | \"alternate\" | \"compact\" | \"horizontal\"\r\n\r\ninterface TimelineEvent {\r\n  /** Stable key. Falls back to the position, which is fine for a list that only grows at the end. */\r\n  id?: string\r\n  /** What people read, such as \"Today, 09:12\". */\r\n  time: React.ReactNode\r\n  /** The machine-readable moment, for the time element. */\r\n  dateTime?: string\r\n  title: React.ReactNode\r\n  /** Shown by every variant except `compact`, which keeps to one line. */\r\n  body?: React.ReactNode\r\n  status?: TimelineStatus\r\n  /** Replaces the status mark. The status is still announced. */\r\n  icon?: React.ReactNode\r\n}\r\n\r\ntype TimelineLabels = Record<Exclude<TimelineStatus, \"neutral\">, string>\r\n\r\nconst DEFAULT_TIMELINE_LABELS: TimelineLabels = {\r\n  current: \"In progress\",\r\n  success: \"Done\",\r\n  warning: \"Needs attention\",\r\n  error: \"Failed\",\r\n}\r\n\r\nconst TIME_CLASS = \"text-[13px] leading-6 text-muted-foreground tabular-nums\"\r\nconst TITLE_CLASS = \"text-sm leading-5 font-medium text-foreground wrap-break-word\"\r\nconst BODY_CLASS = \"text-[13px] leading-5 text-muted-foreground wrap-break-word\"\r\n\r\nfunction EventTime({ event, className }: { event: TimelineEvent; className?: string }) {\r\n  return (\r\n    <time dateTime={event.dateTime} className={cn(TIME_CLASS, className)}>\r\n      {event.time}\r\n    </time>\r\n  )\r\n}\r\n\r\nfunction SpokenStatus({ status, labels }: { status: TimelineStatus; labels: TimelineLabels }) {\r\n  if (status === \"neutral\") return null\r\n  return <span className=\"sr-only\">{labels[status]}: </span>\r\n}\r\n\r\nfunction EventText({ event, labels, className }: { event: TimelineEvent; labels: TimelineLabels; className?: string }) {\r\n  return (\r\n    <div className={cn(\"grid min-w-0 gap-1\", className)}>\r\n      <p className={TITLE_CLASS}>\r\n        <SpokenStatus status={event.status ?? \"neutral\"} labels={labels} />\r\n        {event.title}\r\n      </p>\r\n      {event.body ? <p className={BODY_CLASS}>{event.body}</p> : null}\r\n    </div>\r\n  )\r\n}\r\n\r\ninterface ListProps {\r\n  events: readonly TimelineEvent[]\r\n  draw: boolean\r\n  labels: TimelineLabels\r\n  label?: string\r\n}\r\n\r\nfunction VerticalList({ events, draw, labels, label }: ListProps) {\r\n  return (\r\n    <ol aria-label={label} data-slot=\"timeline-list\" className=\"grid min-w-0\">\r\n      {events.map((event, index) => (\r\n        <li key={event.id ?? index} className=\"relative flex min-w-0 gap-3 pb-6 last:pb-0\">\r\n          {/* Stops short of both markers, so neither needs an opaque fill that would have to match the surface behind it. */}\r\n          {index < events.length - 1 ? <TimelineConnector axis=\"y\" draw={draw} className=\"absolute start-[11.5px] top-7 bottom-1 w-px\" /> : null}\r\n          <TimelineMarker status={event.status ?? \"neutral\"} icon={event.icon} />\r\n          <div className=\"grid min-w-0 flex-1\">\r\n            <EventTime event={event} />\r\n            <EventText event={event} labels={labels} />\r\n          </div>\r\n        </li>\r\n      ))}\r\n    </ol>\r\n  )\r\n}\r\n\r\n/** One column until the container reaches 512 px. Container, not viewport: a wide screen may still give it a narrow column. */\r\nfunction AlternateList({ events, draw, labels, label }: ListProps) {\r\n  return (\r\n    <ol aria-label={label} data-slot=\"timeline-list\" className=\"grid min-w-0\">\r\n      {events.map((event, index) => {\r\n        const contentRight = index % 2 === 0\r\n        return (\r\n          <li\r\n            key={event.id ?? index}\r\n            data-side={contentRight ? \"right\" : \"left\"}\r\n            className=\"relative grid min-w-0 grid-cols-[1.5rem_minmax(0,1fr)] gap-x-3 pb-6 last:pb-0 @lg:grid-cols-[minmax(0,1fr)_1.5rem_minmax(0,1fr)] @lg:gap-x-6\"\r\n          >\r\n            {index < events.length - 1 ? (\r\n              <TimelineConnector axis=\"y\" draw={draw} className=\"absolute start-[11.5px] top-7 bottom-1 w-px @lg:start-1/2 @lg:-translate-x-1/2\" />\r\n            ) : null}\r\n            <span className=\"col-start-1 row-span-2 row-start-1 @lg:col-start-2\">\r\n              <TimelineMarker status={event.status ?? \"neutral\"} icon={event.icon} />\r\n            </span>\r\n            <EventTime event={event} className={cn(\"col-start-2 row-start-1\", contentRight ? \"@lg:col-start-1 @lg:text-right\" : \"@lg:col-start-3\")} />\r\n            <EventText\r\n              event={event}\r\n              labels={labels}\r\n              // On two sides the title shares a row with the time on the far side, so they take the same line height.\r\n              className={cn(\"col-start-2 row-start-2 @lg:[&>p:first-child]:leading-6\", contentRight ? \"@lg:col-start-3 @lg:row-start-1\" : \"@lg:col-start-1 @lg:row-start-1 @lg:text-right\")}\r\n            />\r\n          </li>\r\n        )\r\n      })}\r\n    </ol>\r\n  )\r\n}\r\n\r\nfunction CompactList({ events, draw, labels, label }: ListProps) {\r\n  return (\r\n    <ol aria-label={label} data-slot=\"timeline-list\" className=\"grid min-w-0\">\r\n      {events.map((event, index) => {\r\n        const status = event.status ?? \"neutral\"\r\n        return (\r\n          <li key={event.id ?? index} className=\"relative grid min-w-0 grid-cols-[1rem_minmax(0,1fr)_auto] items-start gap-x-2 py-1.5\">\r\n            {index < events.length - 1 ? <TimelineConnector axis=\"y\" draw={draw} className=\"absolute start-[7.5px] top-[22px] -bottom-2.5 w-px\" /> : null}\r\n            <TimelineDot status={status} />\r\n            <p className=\"min-w-0 text-[13px] leading-5 wrap-break-word text-foreground\">\r\n              {event.title}\r\n              {/* Colour alone cannot carry the status on a dot this small, so it is also written out. */}\r\n              {status === \"neutral\" ? null : <span className={cn(\"ms-2 font-medium\", STATUS_TEXT_CLASS[status])}>{labels[status]}</span>}\r\n            </p>\r\n            <EventTime event={event} className=\"leading-5\" />\r\n          </li>\r\n        )\r\n      })}\r\n    </ol>\r\n  )\r\n}\r\n\r\nfunction HorizontalList({ events, draw, labels, label }: ListProps) {\r\n  return (\r\n    // Focusable because a scroller that only a mouse can move is out of reach for the keyboard. Not rounded: a clip would cut the ends of the line.\r\n    <div\r\n      role=\"region\"\r\n      aria-label={label}\r\n      tabIndex={0}\r\n      data-slot=\"timeline-scroller\"\r\n      className=\"min-w-0 snap-x snap-proximity overflow-x-auto pb-2 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-inset\"\r\n    >\r\n      <ol data-slot=\"timeline-list\" className=\"flex w-max min-w-full\">\r\n        {events.map((event, index) => (\r\n          <li key={event.id ?? index} className=\"relative grid w-44 shrink-0 snap-start content-start gap-2 pe-4 last:pe-0\">\r\n            {index < events.length - 1 ? <TimelineConnector axis=\"x\" draw={draw} className=\"absolute start-7 end-1 top-[11.5px] h-px\" /> : null}\r\n            <TimelineMarker status={event.status ?? \"neutral\"} icon={event.icon} />\r\n            <div className=\"grid min-w-0\">\r\n              <EventTime event={event} />\r\n              <EventText event={event} labels={labels} />\r\n            </div>\r\n          </li>\r\n        ))}\r\n      </ol>\r\n    </div>\r\n  )\r\n}\r\n\r\nconst LIST: Record<TimelineVariant, (props: ListProps) => React.ReactNode> = {\r\n  vertical: VerticalList,\r\n  alternate: AlternateList,\r\n  compact: CompactList,\r\n  horizontal: HorizontalList,\r\n}\r\n\r\nexport { DEFAULT_TIMELINE_LABELS, LIST, type TimelineEvent, type TimelineLabels, type TimelineVariant }\r\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/timeline-parts.tsx",
      "content": "import type * as React from \"react\"\nimport { useRef } from \"react\"\nimport { AlertTriangle, Check, X } from \"lucide-react\"\nimport { motion, useInView, useReducedMotion } from \"motion/react\"\nimport { SPRING_DEFAULT } from \"@/registry/miseui/lib/motion\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype TimelineStatus = \"neutral\" | \"current\" | \"success\" | \"warning\" | \"error\"\n\n/**\n * Success, warning and error are fixed hues because the accent may itself be amber or red. `current` is the one that\n * follows the accent: it says where things stand now, not whether they went well. Soft fills keep a long history\n * quiet, and error alone is solid, since it is the one line that may need someone to act.\n */\nconst MARKER_CLASS: Record<TimelineStatus, string> = {\n  neutral: \"size-2.5 border-2 border-muted-foreground/50\",\n  current: \"size-3 bg-mise ring-4 ring-mise/20\",\n  success: \"size-6 bg-emerald-500/15 text-emerald-700 dark:text-emerald-300\",\n  warning: \"size-6 bg-amber-400/20 text-amber-800 dark:text-amber-300\",\n  error: \"size-6 bg-destructive text-white dark:text-background\",\n}\n\nconst GLYPH: Partial<Record<TimelineStatus, typeof Check>> = { success: Check, warning: AlertTriangle, error: X }\n\nconst DOT_CLASS: Record<TimelineStatus, string> = {\n  neutral: \"bg-muted-foreground/50\",\n  current: \"bg-mise\",\n  success: \"bg-emerald-500\",\n  warning: \"bg-amber-500\",\n  error: \"bg-destructive\",\n}\n\nconst STATUS_TEXT_CLASS: Record<Exclude<TimelineStatus, \"neutral\">, string> = {\n  current: \"text-foreground\",\n  success: \"text-emerald-700 dark:text-emerald-300\",\n  warning: \"text-amber-800 dark:text-amber-300\",\n  error: \"text-destructive\",\n}\n\nfunction TimelineMarker({ status, icon }: { status: TimelineStatus; icon?: React.ReactNode }) {\n  const Glyph = GLYPH[status]\n  return (\n    // The 24 px slot stays the same for every status, so a small marker and a large one share one centre line.\n    <span aria-hidden=\"true\" data-slot=\"timeline-marker-slot\" className=\"flex size-6 shrink-0 items-center justify-center\">\n      {icon ? (\n        <span data-slot=\"timeline-marker\" data-status={status} className=\"flex size-6 items-center justify-center rounded-full bg-muted text-foreground [&_svg]:size-3.5\">\n          {icon}\n        </span>\n      ) : (\n        <span data-slot=\"timeline-marker\" data-status={status} className={cn(\"flex items-center justify-center rounded-full\", MARKER_CLASS[status])}>\n          {Glyph ? <Glyph className=\"size-3.5\" strokeWidth={2.5} /> : null}\n        </span>\n      )}\n    </span>\n  )\n}\n\nfunction TimelineDot({ status }: { status: TimelineStatus }) {\n  return (\n    <span aria-hidden=\"true\" className=\"flex h-5 w-4 shrink-0 items-center justify-center\">\n      <span data-slot=\"timeline-marker\" data-status={status} className={cn(\"size-2 rounded-full\", DOT_CLASS[status])} />\n    </span>\n  )\n}\n\ninterface ConnectorProps {\n  axis: \"x\" | \"y\"\n  draw: boolean\n  className?: string\n}\n\n/**\n * Each stretch draws once when it enters the view. Tying it to scroll progress instead would leave the last stretch\n * half-drawn at the end of a short page, where the scroll can never reach it.\n */\nfunction DrawnConnector({ axis, className }: Omit<ConnectorProps, \"draw\">) {\n  const ref = useRef<HTMLSpanElement>(null)\n  const seen = useInView(ref, { once: true, margin: \"0px 0px -10% 0px\" })\n  const scale = seen ? 1 : 0\n  return (\n    <span ref={ref} aria-hidden=\"true\" data-slot=\"timeline-connector\" data-drawn={seen ? \"true\" : \"false\"} className={cn(\"block overflow-hidden\", className)}>\n      <motion.span\n        className={cn(\"block size-full bg-foreground/30\", axis === \"x\" ? \"origin-left rtl:origin-right\" : \"origin-top\")}\n        initial={axis === \"x\" ? { scaleX: 0 } : { scaleY: 0 }}\n        animate={axis === \"x\" ? { scaleX: scale } : { scaleY: scale }}\n        transition={SPRING_DEFAULT}\n      />\n    </span>\n  )\n}\n\nfunction TimelineConnector({ axis, draw, className }: ConnectorProps) {\n  const reduceMotion = useReducedMotion() ?? false\n  if (draw && !reduceMotion) return <DrawnConnector axis={axis} className={className} />\n  return <span aria-hidden=\"true\" data-slot=\"timeline-connector\" className={cn(\"block bg-border\", className)} />\n}\n\nexport { STATUS_TEXT_CLASS, TimelineConnector, TimelineDot, TimelineMarker, type TimelineStatus }\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "display"
  ],
  "type": "registry:ui"
}