{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"order-status","type":"registry:block","title":"Order status","description":"An order’s route with a parcel riding it: stops light as it passes, the arrival day rolls, and the carrier’s scans slide in.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/copy-button.json?as=dependency","https://hairlineui.com/r/number-roll.json?as=dependency","https://hairlineui.com/r/status-button.json?as=dependency","https://hairlineui.com/r/text-morph.json?as=dependency"],"files":[{"path":"registry/order-status.tsx","type":"registry:component","content":"// Hairline UI · order-status · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { ArrowUpRight, Package } from \"lucide-react\";\nimport { CopyButton } from \"./copy-button\";\nimport { NumberRoll } from \"./number-roll\";\nimport { StatusButton, type ActionStatus } from \"./status-button\";\nimport { TextMorph } from \"./text-morph\";\nimport { useRowMotion } from \"./use-row-motion\";\nimport { EASE_THROW, INTERACTION_FOCUS, useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * ORDER STATUS: the parcel on its route\n *\n *   route     the lit card carries the order's route, Ordered →\n *             Packed → Shipped → Out for delivery → Delivered, as a\n *             hairline; a parcel rides it to where the order is\n *             and every stop it passes lights as it goes by\n *   arriving  the day it arrives is large; when the date moves the\n *             weekday morphs and the day rolls\n *   events    the carrier's scans, newest first; a new one slides\n *             in at the top\n *   cancel    before it ships, Cancel order asks once, then goes\n *             Cancelling → Cancelled and the route dims\n *\n * The order is yours: pass status, eta and events from your\n * database or the carrier's webhook (a refetch, a poll or a\n * socket); onCancel does the cancelling.\n * ───────────────────────────────────────────────────────── */\n\nexport type OrderStage = \"ordered\" | \"packed\" | \"shipped\" | \"out\" | \"delivered\";\n\nexport interface OrderEvent {\n  id: string;\n  at: Date | string | number;\n  title: string;\n  /** Where it happened, e.g. \"Leeds depot\". */\n  place?: string;\n}\n\nexport interface OrderStatusProps {\n  /** Shown as \"Order #HL-2048\". */\n  number: string;\n  status: OrderStage | \"cancelled\";\n  /** When it's expected; when it arrived once delivered. */\n  eta?: Date | string | number;\n  /** e.g. \"by 9pm\", after the date. */\n  window?: string;\n  /** Where it's going, one line. */\n  address?: string;\n  carrier?: string;\n  tracking?: string;\n  trackingUrl?: string;\n  /** Newest first. */\n  events?: OrderEvent[];\n  /** Offered until the order ships; throw an Error to say why not. */\n  onCancel?: () => Promise<unknown>;\n  /** Events shown before Show all. */\n  visibleEvents?: number;\n  locale?: string;\n  className?: string;\n}\n\nconst STAGES: { id: OrderStage; label: string; short: string }[] = [\n  { id: \"ordered\", label: \"Order placed\", short: \"Ordered\" },\n  { id: \"packed\", label: \"Packed\", short: \"Packed\" },\n  { id: \"shipped\", label: \"Shipped\", short: \"Shipped\" },\n  { id: \"out\", label: \"Out for delivery\", short: \"Out\" },\n  { id: \"delivered\", label: \"Delivered\", short: \"Delivered\" },\n];\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst RIDE = 1100;\nconst NONE: OrderEvent[] = [];\n\nconst subscribeNothing = () => () => {};\n/** False on the server and while hydrating: text is formatted in one fixed locale until then, so the server's and the visitor's never disagree. */\nconst useHydrated = () => React.useSyncExternalStore(subscribeNothing, () => true, () => false);\n\nconst mix = (color: string, amount: number, into = \"transparent\") => `color-mix(in oklab, ${color} ${amount}%, ${into})`;\nconst CARD_LIT: React.CSSProperties = {\n  backgroundColor: \"var(--primary)\",\n  backgroundImage: `radial-gradient(120% 80% at 0% 0%, ${mix(\"var(--primary-foreground)\", 22)} 0%, transparent 55%), linear-gradient(175deg, transparent 40%, ${mix(\"black\", 24)} 100%)`,\n  boxShadow: `inset 0 1px 0 ${mix(\"var(--primary-foreground)\", 36)}, inset 0 0 0 1px ${mix(\"var(--primary-foreground)\", 12)}`,\n};\nconst CARD: React.CSSProperties = {\n  backgroundImage: `linear-gradient(180deg, var(--card), ${mix(\"var(--card)\", 95, \"var(--foreground)\")})`,\n  boxShadow: `0 0 0 1px var(--border), inset 0 1px 0 ${mix(\"var(--background)\", 85)}`,\n};\n\nexport function OrderStatus({\n  number,\n  status,\n  eta,\n  window: timeWindow,\n  address,\n  carrier,\n  tracking,\n  trackingUrl,\n  events = NONE,\n  onCancel,\n  visibleEvents = 3,\n  locale,\n  className = \"\",\n}: OrderStatusProps) {\n  const reduced = useInteractionReducedMotion();\n  // Dates depend on the visitor's locale and time zone, so they're written once the block is in the browser.\n  const hydrated = useHydrated();\n  const baseId = React.useId();\n  const [cancel, setCancel] = React.useState<{ status: ActionStatus; confirm: boolean; error?: string }>({ status: \"idle\", confirm: false });\n  const [expanded, setExpanded] = React.useState(false);\n  const listRef = React.useRef<HTMLOListElement>(null);\n  const ghostRef = React.useRef<HTMLDivElement>(null);\n\n  const cancelled = status === \"cancelled\" || cancel.status === \"success\";\n  const at = Math.max(0, STAGES.findIndex((s) => s.id === status));\n  const stage = cancelled ? null : STAGES[at];\n  const progress = at / (STAGES.length - 1);\n\n  // Where the parcel set off from, so stops light up as it passes them, not all at once.\n  const [from, setFrom] = React.useState(at);\n  const [seenAt, setSeenAt] = React.useState(at);\n  if (at !== seenAt) {\n    setFrom(seenAt);\n    setSeenAt(at);\n  }\n  const passDelay = (i: number) => (reduced || at <= from || i <= from ? 0 : ((i - from) / (at - from)) * RIDE * 0.85);\n\n  const fmt = React.useMemo(\n    () => ({\n      weekday: new Intl.DateTimeFormat(locale, { weekday: \"long\" }),\n      month: new Intl.DateTimeFormat(locale, { month: \"short\" }),\n      time: new Intl.DateTimeFormat(locale, { hour: \"numeric\", minute: \"2-digit\" }),\n      day: new Intl.DateTimeFormat(locale, { day: \"numeric\", month: \"short\" }),\n    }),\n    [locale],\n  );\n  const etaDate = eta !== undefined && hydrated ? new Date(eta) : null;\n\n  const rows = expanded ? events : events.slice(0, visibleEvents);\n  const ranks = React.useMemo(() => new Map(events.map((e, i) => [e.id, i])), [events]);\n  useRowMotion(listRef, { reduced, ranks, ghosts: ghostRef });\n\n  const canCancel = Boolean(onCancel) && (cancel.status === \"success\" || (!cancelled && (status === \"ordered\" || status === \"packed\")));\n  const cancelOrder = async () => {\n    if (!onCancel || cancel.status === \"pending\") return;\n    if (!cancel.confirm) return setCancel({ status: \"idle\", confirm: true });\n    setCancel({ status: \"pending\", confirm: true });\n    try {\n      await onCancel();\n      setCancel({ status: \"success\", confirm: true });\n    } catch (e) {\n      setCancel({ status: \"error\", confirm: true, error: e instanceof Error && e.message ? e.message : \"Couldn’t cancel. Try again.\" });\n    }\n  };\n\n  React.useEffect(() => {\n    if (!cancel.confirm || cancel.status !== \"idle\") return;\n    const timer = window.setTimeout(() => setCancel({ status: \"idle\", confirm: false }), 4000);\n    return () => window.clearTimeout(timer);\n  }, [cancel]);\n\n  const headline = cancelled ? \"Cancelled\" : status === \"delivered\" ? \"Delivered\" : \"Arriving\";\n\n  return (\n    <section aria-labelledby={`${baseId}-title`} className={`w-full max-w-[420px] rounded-[28px] p-2 text-card-foreground ${className}`} style={CARD}>\n      {/* Its natural width where the parent sizes to its content, without forcing it on narrow screens. */}\n      <div aria-hidden=\"true\" data-natural-width className=\"grid h-0\" style={{ gridTemplateColumns: \"minmax(0, 420px)\" }} />\n      <div className=\"@container w-full\">\n        {/* The one lit surface: the arrival and the route. */}\n        <div className=\"rounded-[22px] p-4 text-primary-foreground\" style={{ ...CARD_LIT, filter: cancelled ? \"saturate(0.35)\" : \"none\", transition: reduced ? \"none\" : `filter 500ms ${EASE}` }}>\n          <div className=\"flex items-center justify-between gap-3 text-[12.5px]\">\n            <h2 id={`${baseId}-title`} className=\"font-mono text-primary-foreground/75\">\n              Order #{number}\n            </h2>\n            <span className=\"rounded-full px-2 py-0.5 font-medium\" style={{ background: mix(\"var(--primary-foreground)\", 16) }} role=\"status\">\n              <TextMorph>{cancelled ? \"Cancelled\" : (stage?.label ?? \"\")}</TextMorph>\n            </span>\n          </div>\n\n          <p className=\"mt-3 text-[12.5px] text-primary-foreground/70\">\n            <TextMorph>{headline}</TextMorph>\n          </p>\n          <p className=\"mt-0.5 flex items-baseline gap-[0.28em] text-[30px] font-medium leading-none tracking-[-0.035em]\" suppressHydrationWarning>\n            {etaDate && !cancelled ? (\n              <>\n                <TextMorph>{fmt.weekday.format(etaDate)}</TextMorph>\n                <span className=\"tabular-nums\">\n                  <NumberRoll value={etaDate.getDate()} duration={800} />\n                </span>\n                <TextMorph className=\"text-primary-foreground/70\">{fmt.month.format(etaDate)}</TextMorph>\n              </>\n            ) : (\n              <span className=\"text-primary-foreground/60\">{cancelled ? \"Not on its way\" : hydrated ? \"Date to follow\" : \" \"}</span>\n            )}\n          </p>\n          {(timeWindow || address) && !cancelled && (\n            <p className=\"mt-1.5 truncate text-[12.5px] text-primary-foreground/70\">\n              {[status === \"delivered\" ? null : timeWindow, address ? `to ${address}` : null].filter(Boolean).join(\" · \")}\n            </p>\n          )}\n\n          {/* The route: stops on a hairline, the parcel riding to where the order is. */}\n          <div className=\"relative mt-5 px-3\" style={{ opacity: cancelled ? 0.5 : 1, transition: \"opacity 400ms\" }}>\n            <div className=\"relative h-6\">\n              <span aria-hidden=\"true\" className=\"absolute inset-x-0 top-1/2 h-px -translate-y-1/2\" style={{ background: mix(\"var(--primary-foreground)\", 26) }} />\n              <span\n                aria-hidden=\"true\"\n                className=\"absolute inset-x-0 top-1/2 h-[2px] origin-left -translate-y-1/2 rounded-full\"\n                style={{ background: \"var(--primary-foreground)\", transform: `scaleX(${progress})`, transition: reduced ? \"none\" : `transform ${RIDE}ms ${EASE}` }}\n              />\n              <ol className=\"absolute inset-0 flex items-center justify-between\">\n                {STAGES.map((s, i) => {\n                  const lit = !cancelled && i <= at;\n                  return (\n                    <li key={s.id} className=\"relative flex size-0 items-center justify-center\" aria-current={i === at && !cancelled ? \"step\" : undefined}>\n                      <span className=\"sr-only\">{`${s.label}${lit ? (i === at ? \", current\" : \", done\") : \"\"}`}</span>\n                      <span\n                        aria-hidden=\"true\"\n                        className=\"absolute size-2.5 rounded-full\"\n                        style={{\n                          background: lit ? \"var(--primary-foreground)\" : \"var(--primary)\",\n                          boxShadow: `0 0 0 1.5px ${lit ? \"var(--primary-foreground)\" : mix(\"var(--primary-foreground)\", 45)}`,\n                          transform: lit && !reduced ? \"scale(1)\" : \"scale(0.8)\",\n                          transition: reduced ? \"none\" : `background-color 240ms ${EASE} ${passDelay(i)}ms, transform 420ms ${EASE_THROW} ${passDelay(i)}ms, box-shadow 240ms ${passDelay(i)}ms`,\n                        }}\n                      />\n                    </li>\n                  );\n                })}\n              </ol>\n              {/* The parcel rides the right edge of a full-width layer that slides in from the left: one composited transform,\n                    and nothing past the card's right edge to scroll to. */}\n              <span aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\" style={{ transform: `translateX(${(progress - 1) * 100}%)`, transition: reduced ? \"none\" : `transform ${RIDE}ms ${EASE}` }}>\n                <span\n                  className=\"absolute right-0 top-1/2 flex size-7 translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-[9px]\"\n                  style={{ background: \"var(--primary-foreground)\", color: \"var(--primary)\", boxShadow: `0 0 0 3px ${mix(\"var(--primary-foreground)\", 22)}`, opacity: cancelled ? 0 : 1, transition: \"opacity 300ms\" }}\n                >\n                  <Package className=\"size-4\" strokeWidth={1.9} />\n                </span>\n              </span>\n            </div>\n            <div aria-hidden=\"true\" className=\"relative mt-2 h-4 text-[11px]\">\n              {STAGES.map((s, i) => (\n                <span\n                  key={s.id}\n                  className=\"absolute top-0 whitespace-nowrap\"\n                  style={{\n                    left: `${(i / (STAGES.length - 1)) * 100}%`,\n                    transform: i === 0 ? \"translateX(-12px)\" : i === STAGES.length - 1 ? \"translateX(calc(-100% + 12px))\" : \"translateX(-50%)\",\n                    color: !cancelled && i <= at ? \"var(--primary-foreground)\" : mix(\"var(--primary-foreground)\", 55),\n                    fontWeight: i === at && !cancelled ? 500 : 400,\n                    transition: `color 240ms ${passDelay(i)}ms`,\n                  }}\n                >\n                  {s.short}\n                </span>\n              ))}\n            </div>\n          </div>\n        </div>\n\n        {/* Flat: tracking, the scans, and Cancel while it can be. */}\n        {(carrier || tracking) && (\n          <div className=\"mx-1 mt-2 flex h-10 items-center gap-2 rounded-full px-3 text-[13px] shadow-[inset_0_0_0_1px_var(--border)]\">\n            <span className=\"shrink-0 text-muted-foreground\">{carrier ?? \"Tracking\"}</span>\n            {tracking && <span className=\"min-w-0 flex-1 truncate font-mono text-[12.5px] text-foreground\">{tracking}</span>}\n            {tracking && <CopyButton text={tracking} label=\"Copy tracking number\" size=\"sm\" variant=\"ghost\" className=\"shrink-0\" />}\n            {trackingUrl && (\n              <a href={trackingUrl} target=\"_blank\" rel=\"noreferrer\" className={`-mr-1 inline-flex h-7 shrink-0 items-center gap-0.5 rounded-full px-2 text-[12.5px] text-primary hover:bg-primary/10 ${INTERACTION_FOCUS}`}>\n                Track\n                <ArrowUpRight aria-hidden=\"true\" className=\"size-3.5\" />\n              </a>\n            )}\n          </div>\n        )}\n\n        {events.length > 0 && (\n          <div className=\"px-3 pb-1 pt-3\">\n            <div className=\"flex items-center justify-between\">\n              <h3 className=\"text-[12.5px] font-normal text-muted-foreground\">Updates</h3>\n              {events.length > visibleEvents && (\n                <button type=\"button\" aria-expanded={expanded} onClick={() => setExpanded((v) => !v)} className={`-mr-2 cursor-pointer rounded-full px-2 py-1 text-[12.5px] text-foreground hover:bg-accent ${INTERACTION_FOCUS}`}>\n                  <TextMorph>{expanded ? \"Show less\" : `Show all ${events.length}`}</TextMorph>\n                </button>\n              )}\n            </div>\n            <div className=\"relative mt-1\">\n              <div ref={ghostRef} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\" />\n              <ol ref={listRef} className=\"relative\">\n                {rows.map((e, i) => {\n                  const when = new Date(e.at);\n                  return (\n                    <li key={e.id} data-row-id={e.id} className=\"relative flex gap-3 py-2 pl-4\">\n                      <span aria-hidden=\"true\" className={`absolute left-0 top-[13px] size-[7px] rounded-full ${i === 0 && !cancelled ? \"bg-primary shadow-[0_0_0_3px_color-mix(in_oklab,var(--primary)_18%,transparent)]\" : \"bg-border\"}`} />\n                      {i < rows.length - 1 && <span aria-hidden=\"true\" className=\"absolute bottom-[-7px] left-[3px] top-[24px] w-px bg-border\" />}\n                      <span className=\"min-w-0 flex-1\">\n                        <span className={`block text-[13.5px] ${i === 0 ? \"font-medium text-foreground\" : \"text-foreground/85\"}`}>{e.title}</span>\n                        {e.place && <span className=\"block truncate text-[12px] text-muted-foreground\">{e.place}</span>}\n                      </span>\n                      <span className=\"shrink-0 pt-px text-right text-[12px] tabular-nums text-muted-foreground\" suppressHydrationWarning>\n                        {hydrated ? fmt.day.format(when) : \"\"}\n                        <span className=\"block\">{hydrated ? fmt.time.format(when) : \"\"}</span>\n                      </span>\n                    </li>\n                  );\n                })}\n              </ol>\n            </div>\n          </div>\n        )}\n\n        {canCancel && (\n          <div className=\"flex flex-col items-center px-3 pb-1.5 pt-1\">\n            <StatusButton\n              variant=\"ghost\"\n              status={cancel.status}\n              labels={{ idle: cancel.confirm ? \"Yes, cancel this order\" : \"Cancel order\", pending: \"Cancelling\", success: \"Cancelled\", error: \"Try again\" }}\n              onClick={() => void cancelOrder()}\n              className={`h-9 px-4 text-[13px] ${cancel.confirm && cancel.status === \"idle\" ? \"text-red-600 hover:text-red-600 dark:text-red-400 dark:hover:text-red-400\" : \"\"}`}\n            />\n            {cancel.error && (\n              <p role=\"alert\" className=\"mt-1 text-center text-[12px] text-red-600 dark:text-red-400\">\n                {cancel.error}\n              </p>\n            )}\n          </div>\n        )}\n      </div>\n    </section>\n  );\n}\n\nexport default OrderStatus;\n"},{"path":"registry/ui-interaction-motion.ts","type":"registry:component","content":"// Hairline UI · order-status · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\n\nexport const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';\nexport const EASE_SETTLE = 'cubic-bezier(0.22, 1.18, 0.36, 1)';\n/** Thrown things (indicators, thumbs): a slight overshoot on transform only. */\nexport const EASE_THROW = 'cubic-bezier(0.34, 1.36, 0.64, 1)';\n/**\n * The press every button primitive shares: a 3% give that eases in and settles back.\n * Tailwind v4's scale-* writes the `scale` property, so `scale` has to be in the list\n * or the press snaps.\n */\nexport const INTERACTION_PRESS =\n  'transition-[background-color,color,box-shadow,opacity,scale] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] active:scale-[0.97] motion-reduce:transition-none motion-reduce:active:scale-100';\nexport const INTERACTION_FOCUS ='focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background';\n\nconst query = '(prefers-reduced-motion: reduce)';\nconst subscribe = (notify: () => void) => {\n  const media = window.matchMedia(query);\n  media.addEventListener('change', notify);\n  return () => media.removeEventListener('change', notify);\n};\nexport const useInteractionReducedMotion = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);\n\nexport function useInteractionActivity<T extends Element>() {\n  const ref = React.useRef<T>(null);\n  const [visible, setVisible] = React.useState(false);\n  const [hidden, setHidden] = React.useState(false);\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));\n    observer.observe(el);\n    const visibility = () => setHidden(document.hidden);\n    visibility(); document.addEventListener('visibilitychange', visibility);\n    return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); };\n  }, []);\n  return { ref, active: visible && !hidden };\n}\n\nexport function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties {\n  return { opacity: on ? 1 : 0, transform: on ? 'none' : 'scale(0.6)', filter: on ? 'blur(0px)' : 'blur(3px)', transition: reduced ? 'none' : `opacity 220ms ${EASE_OUT}, transform 380ms ${EASE_OUT}, filter 220ms ${EASE_OUT}` };\n}\n\n/** Sample recent movement rather than averaging a long drag and losing its release velocity. */\nexport type PointerSample = { x: number; time: number };\nexport function samplePointer(samples: PointerSample[], x: number, time: number) {\n  samples.push({ x, time });\n  while (samples.length > 2 && time - samples[0].time > 100) samples.shift();\n}\nexport function releaseVelocity(samples: PointerSample[], now: number) {\n  if (samples.length < 2 || now - samples[samples.length - 1].time > 100) return 0;\n  const first = samples[0], last = samples[samples.length - 1];\n  return (last.x - first.x) / Math.max(1, last.time - first.time);\n}\n\n/** Smooth Apple/rational rubber-band curve: continuous exponential-like resistance without linear kinks. */\nexport function rubberBand(overshoot: number, dimension = 320, constant = 0.55): number {\n  const d = dimension || 320;\n  const abs = Math.abs(overshoot);\n  return Math.sign(overshoot) * (1 - 1 / ((abs * constant) / d + 1)) * d;\n}\n\n/** Projects resting travel from release velocity (velocity in px/ms; time in seconds). */\nexport function projectInertia(distance: number, velocity: number, time = 0.12): number {\n  return distance + velocity * 1000 * time;\n}\n\n/** Smooths incoming release velocity toward a boundary so springs don't slam or clip into walls. */\nexport function softenVelocity(velocity: number, distanceToTarget: number, factor = 10): number {\n  const maxAllowed = Math.abs(distanceToTarget) * factor;\n  return Math.sign(velocity) * Math.min(Math.abs(velocity), maxAllowed);\n}\n\n/** A damped spring sampled by WAAPI: tuned for organic lightness with a hint of life (3% breath). */\nexport function springFrames(\n  from: number,\n  to: number,\n  velocity = 0,\n  duration = 480,\n  config: { omega?: number; damping?: number; steps?: number } = {},\n) {\n  const { omega = 16, damping = 0.74, steps = 36 } = config;\n  const damped = omega * Math.sqrt(Math.max(0.001, 1 - damping * damping));\n  const displacement = from - to;\n  const coefficient = (velocity * 1000 + damping * omega * displacement) / damped;\n  return Array.from({ length: steps + 1 }, (_, i) => {\n    const t = (i / steps) * (duration / 1000);\n    const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t));\n    return { value: i === steps ? to : value, offset: i / steps };\n  });\n}\n\n"},{"path":"registry/use-row-motion.ts","type":"registry:component","content":"// Hairline UI · order-status · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * ROW MOTION: rows that keep their place on screen\n *\n *   glide     after any render (a sort, a filter, new data from\n *             the server) every row that moved glides from its\n *             old place to its new one. Glides are additive, so\n *             a change that lands mid-glide adds its own offset\n *             instead of restarting anything\n *   travel    a row that sorts onto the page slides in from the\n *             side where it ranked before; one that sorts off\n *             slides out toward its new rank\n *   enter     a row that's new to the list fades in where it\n *             lands while the rows below glide clear of it\n *   leave     a row that's gone folds away where it stood. Its\n *             copy is the row's own element, detached by React\n *             and moved into a layer React never renders into,\n *             so a leaving row costs no render at all\n *   stagger   a sort can ask for the rows to set off one after\n *             another, top first\n *\n * Positions are layout offsets (transforms ignored), kept after\n * every render and whenever the list changes size (a row\n * opening its detail), so they're never stale. Rows are found\n * by data-row-id; ranks are their places in the whole sorted\n * list, not just the page.\n * ───────────────────────────────────────────────────────── */\n\ntype Seen = { top: number; height: number; el: HTMLElement };\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst GLIDE = 480;\n\nexport function useRowMotion(\n  container: React.RefObject<HTMLElement | null>,\n  { reduced, resetKey, ranks, ghosts }: { reduced: boolean; resetKey?: string | number; ranks: ReadonlyMap<string, number>; ghosts: React.RefObject<HTMLElement | null> },\n) {\n  const seen = React.useRef(new Map<string, Seen>());\n  const lastRanks = React.useRef(ranks);\n  const lastKey = React.useRef(resetKey);\n  const staggerNext = React.useRef(false);\n\n  const read = React.useCallback(() => {\n    const out = new Map<string, Seen>();\n    container.current?.querySelectorAll<HTMLElement>(\"[data-row-id]\").forEach((el) => out.set(el.dataset.rowId!, { top: el.offsetTop, height: el.offsetHeight, el }));\n    return out;\n  }, [container]);\n\n  /** The row React just removed, back on screen where it stood, leaving: sliding toward where it went, or folding. */\n  const leave = (was: Seen, dy: number) => {\n    const layer = ghosts.current;\n    const el = was.el;\n    if (!layer || el.isConnected) return;\n    // No getAnimations() here (it flushes style for the whole page): the exit below is added last and replaces any\n    // glide still on the element.\n    el.removeAttribute(\"data-row-id\");\n    el.removeAttribute(\"tabindex\");\n    el.setAttribute(\"aria-hidden\", \"true\");\n    el.inert = true;\n    Object.assign(el.style, { position: \"absolute\", left: \"0\", right: \"0\", top: `${was.top}px`, height: `${was.height}px`, margin: \"0\", overflow: \"hidden\" });\n    layer.appendChild(el);\n    const a = el.animate(\n      dy\n        ? [\n            { transform: \"translateY(0px)\", opacity: 1, filter: \"blur(0px)\" },\n            { transform: `translateY(${dy}px)`, opacity: 0, filter: \"blur(3px)\" },\n          ]\n        : [\n            { opacity: 1, transform: \"none\", filter: \"blur(0px)\" },\n            { opacity: 0, transform: \"scale(0.985)\", filter: \"blur(4px)\" },\n          ],\n      { duration: dy ? 360 : 280, easing: EASE, fill: \"forwards\" },\n    );\n    a.onfinish = a.oncancel = () => el.remove();\n  };\n\n  React.useLayoutEffect(() => {\n    const el = container.current;\n    if (!el) return;\n    const before = seen.current;\n    const after = read();\n    const prevRanks = lastRanks.current;\n    seen.current = after;\n    lastRanks.current = ranks;\n    const stagger = staggerNext.current;\n    staggerNext.current = false;\n    if (lastKey.current !== resetKey) {\n      // A different page: the caller slides the whole body; nothing glides and nothing folds.\n      lastKey.current = resetKey;\n      return;\n    }\n    if (reduced || !before.size) return;\n\n    // A typical row's height. Rows that come onto or leave the page only hint at the way they went (a row and a\n    // quarter at most), so the list never shows holes while they travel.\n    const heights = [...after.values()].map((b) => b.height).sort((a, b) => a - b);\n    const rowH = heights[Math.floor(heights.length / 2)] ?? 48;\n    const clamp = (dy: number) => Math.max(-rowH * 1.25, Math.min(rowH * 1.25, dy));\n\n    let order = 0;\n    after.forEach((now, id) => {\n      const row = now.el;\n      const was = before.get(id);\n      const delay = stagger ? Math.min(order++ * 16, 200) : 0;\n      if (was) {\n        const dy = was.top - now.top;\n        if (Math.abs(dy) < 0.5) return;\n        row.animate([{ transform: `translateY(${dy}px)`, zIndex: 1 }, { transform: \"translateY(0px)\", zIndex: 1 }], { duration: GLIDE, delay, easing: EASE, composite: \"add\", fill: \"backwards\" });\n        return;\n      }\n      const from = prevRanks.get(id);\n      const to = ranks.get(id);\n      if (from !== undefined && to !== undefined) {\n        // It ranked somewhere off the page: it comes in from that side.\n        row.animate(\n          [\n            { transform: `translateY(${clamp((from - to) * rowH)}px)`, opacity: 0, filter: \"blur(3px)\" },\n            { transform: \"translateY(0px)\", opacity: 1, filter: \"blur(0px)\" },\n          ],\n          { duration: GLIDE, delay: delay / 2, easing: EASE, fill: \"backwards\" },\n        );\n        return;\n      }\n      row.animate(\n        [\n          { opacity: 0, filter: \"blur(4px)\" },\n          { opacity: 1, filter: \"blur(0px)\" },\n        ],\n        { duration: 420, delay: 90, easing: EASE, fill: \"backwards\" },\n      );\n    });\n\n    // Still in the list, just not on this page: its copy slides toward where it went. Gone for good: it folds.\n    before.forEach((was, id) => {\n      if (after.has(id)) return;\n      const from = prevRanks.get(id);\n      const to = ranks.get(id);\n      leave(was, from !== undefined && to !== undefined ? clamp((to - from) * rowH) || rowH : 0);\n    });\n  });\n\n  // The list changed size without a render of ours (a detail opening by transition): keep the positions true.\n  React.useEffect(() => {\n    const el = container.current;\n    if (!el) return;\n    const observer = new ResizeObserver(() => {\n      seen.current = read();\n    });\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, [container, read]);\n\n  return {\n    /** The next change (a sort) sets the rows off one after another. */\n    stagger: () => {\n      staggerNext.current = true;\n    },\n  };\n}\n"}],"css":{"@keyframes ui-col-in":{"from":{"max-width":"0","opacity":"0"},"to":{"max-width":"1em","opacity":"1"}},"@keyframes ui-col-out":{"from":{"max-width":"1em","opacity":"1"},"to":{"max-width":"0","opacity":"0"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/order-status. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}