{
  "name": "status-island",
  "type": "registry:component",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Status Island\n * @version: 1.0.0\n * @date: 2026-10-15\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport {\n  AnimatePresence,\n  MotionConfig,\n  motion,\n  type Transition,\n} from \"motion/react\";\nimport {\n  type FocusEvent,\n  type KeyboardEvent,\n  type PointerEvent,\n  useCallback,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type IslandTaskStatus = \"running\" | \"done\" | \"error\";\n\nexport interface IslandTask {\n  id: string;\n  /** What is happening, e.g. \"Uploading 12 photos\". */\n  label: string;\n  /** Optional second part, shown dimmed after the label. */\n  detail?: string;\n  status: IslandTaskStatus;\n  /** 0 to 1 while running. Leave it out for work without a known length. */\n  progress?: number;\n  /** A follow-up, like Undo, Open or Retry. */\n  action?: { label: string; onClick: () => void };\n}\n\nexport interface StatusIslandProps {\n  /** Oldest first; the newest task is the one the island shows. */\n  tasks: IslandTask[];\n  /** Called when a finished task has been on screen for `dismissAfter`. */\n  onDismiss?: (id: string) => void;\n  /** Milliseconds a done task stays. Paused while the island is open. */\n  dismissAfter?: number;\n  className?: string;\n}\n\nconst MORPH: Transition = { type: \"spring\", duration: 0.45, bounce: 0.18 };\nconst SWAP: Transition = { duration: 0.22, ease: [0.23, 1, 0.32, 1] };\nconst EASE_OUT = \"cubic-bezier(0.23, 1, 0.32, 1)\";\n// Tasks that peek out behind the island when it is closed.\nconst MAX_PEEK = 2;\n\n/** A number whose digits roll to their new value. */\nfunction RollingNumber({ value }: { value: number }) {\n  const text = String(value);\n  return (\n    <span aria-hidden=\"true\" className=\"inline-flex tabular-nums\">\n      {text.split(\"\").map((char, i) => {\n        // Keyed from the right, so the ones stay the ones when a digit is added.\n        const key = text.length - i;\n        const digit = Number(char);\n        return (\n          <span className=\"relative inline-block h-4 overflow-hidden\" key={key}>\n            <span\n              className=\"flex flex-col transition-transform duration-200 motion-reduce:transition-none\"\n              style={{\n                transform: `translateY(${-digit * 10}%)`,\n                transitionTimingFunction: EASE_OUT,\n              }}\n            >\n              {\"0123456789\".split(\"\").map((d) => (\n                <span className=\"h-4 leading-4\" key={d}>\n                  {d}\n                </span>\n              ))}\n            </span>\n          </span>\n        );\n      })}\n    </span>\n  );\n}\n\nfunction TaskIcon({ task }: { task: IslandTask }) {\n  if (task.status === \"done\") {\n    return (\n      <svg\n        aria-hidden=\"true\"\n        className=\"size-4 shrink-0 text-emerald-400\"\n        fill=\"none\"\n        viewBox=\"0 0 16 16\"\n      >\n        <circle cx=\"8\" cy=\"8\" fill=\"currentColor\" opacity=\"0.18\" r=\"8\" />\n        <motion.path\n          animate={{ pathLength: 1 }}\n          d=\"M4.75 8.25 7 10.5l4.25-4.75\"\n          initial={{ pathLength: 0 }}\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          strokeWidth=\"1.75\"\n          transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1], delay: 0.08 }}\n        />\n      </svg>\n    );\n  }\n  if (task.status === \"error\") {\n    return (\n      <svg\n        aria-hidden=\"true\"\n        className=\"size-4 shrink-0 text-red-400\"\n        fill=\"none\"\n        viewBox=\"0 0 16 16\"\n      >\n        <circle cx=\"8\" cy=\"8\" fill=\"currentColor\" opacity=\"0.18\" r=\"8\" />\n        <path\n          d=\"M8 4.5v4\"\n          stroke=\"currentColor\"\n          strokeLinecap=\"round\"\n          strokeWidth=\"1.75\"\n        />\n        <circle cx=\"8\" cy=\"11.25\" fill=\"currentColor\" r=\"1\" />\n      </svg>\n    );\n  }\n  const known = typeof task.progress === \"number\";\n  const circumference = 2 * Math.PI * 6;\n  const progress = known ? Math.min(1, Math.max(0, task.progress ?? 0)) : 0.25;\n  return (\n    <svg\n      aria-hidden=\"true\"\n      className={cn(\n        \"size-4 shrink-0 -rotate-90\",\n        !known && \"animate-spin motion-reduce:animate-none\"\n      )}\n      fill=\"none\"\n      viewBox=\"0 0 16 16\"\n    >\n      <circle\n        cx=\"8\"\n        cy=\"8\"\n        r=\"6\"\n        stroke=\"currentColor\"\n        strokeOpacity=\"0.18\"\n        strokeWidth=\"2\"\n      />\n      <circle\n        cx=\"8\"\n        cy=\"8\"\n        r=\"6\"\n        stroke=\"currentColor\"\n        strokeDasharray={circumference}\n        strokeDashoffset={circumference * (1 - progress)}\n        strokeLinecap=\"round\"\n        strokeWidth=\"2\"\n        style={{ transition: `stroke-dashoffset 250ms ${EASE_OUT}` }}\n      />\n    </svg>\n  );\n}\n\nfunction TaskTrailing({ task }: { task: IslandTask }) {\n  if (task.action) {\n    const { label, onClick } = task.action;\n    return (\n      <button\n        className=\"-my-1 shrink-0 rounded-full bg-white/12 px-2.5 py-1 font-medium text-white text-xs transition-colors duration-150 hover:bg-white/20 focus-visible:outline-2 focus-visible:outline-white/60\"\n        onClick={(event) => {\n          event.stopPropagation();\n          onClick();\n        }}\n        type=\"button\"\n      >\n        {label}\n      </button>\n    );\n  }\n  if (task.status === \"running\" && typeof task.progress === \"number\") {\n    const percent = Math.round(Math.min(1, Math.max(0, task.progress)) * 100);\n    return (\n      <span className=\"flex shrink-0 items-center text-white/55 text-xs\">\n        <RollingNumber value={percent} />%\n      </span>\n    );\n  }\n  return null;\n}\n\nfunction TaskRow({ task }: { task: IslandTask }) {\n  return (\n    <div className=\"flex min-w-0 items-center gap-2.5\">\n      <TaskIcon task={task} />\n      <span className=\"min-w-0 flex-1 truncate text-[13px] leading-4\">\n        {task.label}\n        {task.detail ? (\n          <span className=\"text-white/50\"> · {task.detail}</span>\n        ) : null}\n      </span>\n      <TaskTrailing task={task} />\n    </div>\n  );\n}\n\nfunction describe(task: IslandTask) {\n  return [task.label, task.detail].filter(Boolean).join(\", \");\n}\n\n/**\n * A pill that reports background work. It reshapes itself as tasks change\n * and opens into a list of everything in flight on hover, focus or tap.\n */\nexport function StatusIsland({\n  tasks,\n  onDismiss,\n  dismissAfter = 4000,\n  className,\n}: StatusIslandProps) {\n  const [open, setOpen] = useState(false);\n  const [announcement, setAnnouncement] = useState(\"\");\n  const doneAt = useRef(new Map<string, number>());\n  const lastStatus = useRef(new Map<string, IslandTaskStatus>());\n  const current = tasks.at(-1);\n  const behind = Math.min(tasks.length - 1, MAX_PEEK);\n  const expanded = open && tasks.length > 0;\n  const islandRef = useRef<HTMLDivElement>(null);\n\n  // A tap anywhere else closes the list on touch screens.\n  useEffect(() => {\n    if (!expanded) {\n      return;\n    }\n    const close = (event: globalThis.PointerEvent) => {\n      if (!islandRef.current?.contains(event.target as Node)) {\n        setOpen(false);\n      }\n    };\n    document.addEventListener(\"pointerdown\", close);\n    return () => document.removeEventListener(\"pointerdown\", close);\n  }, [expanded]);\n\n  // Announce new tasks and status changes, not every progress tick.\n  useEffect(() => {\n    for (const task of tasks) {\n      if (lastStatus.current.get(task.id) !== task.status) {\n        lastStatus.current.set(task.id, task.status);\n        setAnnouncement(describe(task));\n      }\n    }\n    const ids = new Set(tasks.map((task) => task.id));\n    for (const id of lastStatus.current.keys()) {\n      if (!ids.has(id)) {\n        lastStatus.current.delete(id);\n        doneAt.current.delete(id);\n      }\n    }\n  }, [tasks]);\n\n  // Finished tasks leave on their own, but never while someone is reading.\n  useEffect(() => {\n    if (!onDismiss || expanded) {\n      return;\n    }\n    const now = Date.now();\n    const timers: number[] = [];\n    for (const task of tasks) {\n      if (task.status !== \"done\") {\n        continue;\n      }\n      const since = doneAt.current.get(task.id) ?? now;\n      doneAt.current.set(task.id, since);\n      const left = Math.max(0, dismissAfter - (now - since));\n      timers.push(window.setTimeout(() => onDismiss(task.id), left));\n    }\n    return () => {\n      for (const timer of timers) {\n        window.clearTimeout(timer);\n      }\n    };\n  }, [tasks, expanded, onDismiss, dismissAfter]);\n\n  // Reading restarts the clock, so a task doesn't vanish as the pointer leaves.\n  useEffect(() => {\n    if (expanded) {\n      const now = Date.now();\n      for (const id of doneAt.current.keys()) {\n        doneAt.current.set(id, now);\n      }\n    }\n  }, [expanded]);\n\n  const handlePointerEnter = useCallback((event: PointerEvent) => {\n    if (event.pointerType === \"mouse\") {\n      setOpen(true);\n    }\n  }, []);\n\n  const handlePointerLeave = useCallback((event: PointerEvent) => {\n    if (event.pointerType === \"mouse\") {\n      setOpen(false);\n    }\n  }, []);\n\n  const handleBlur = useCallback((event: FocusEvent<HTMLDivElement>) => {\n    if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {\n      setOpen(false);\n    }\n  }, []);\n\n  const handleKeyDown = useCallback((event: KeyboardEvent) => {\n    if (event.key === \"Escape\") {\n      setOpen(false);\n    }\n  }, []);\n\n  return (\n    <MotionConfig reducedMotion=\"user\">\n      <div className={cn(\"relative flex flex-col items-center\", className)}>\n        <output aria-live=\"polite\" className=\"sr-only\">\n          {announcement}\n        </output>\n        <AnimatePresence>\n          {current ? (\n            <motion.div\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              className=\"relative\"\n              exit={{ opacity: 0, y: 8, filter: \"blur(4px)\" }}\n              initial={{ opacity: 0, y: 8, filter: \"blur(4px)\" }}\n              key=\"island\"\n              transition={SWAP}\n            >\n              {/* Tasks waiting behind the current one peek out above it. */}\n              {Array.from({ length: behind }, (_, i) => (\n                <motion.div\n                  animate={{\n                    opacity: expanded ? 0 : 1 - (i + 1) * 0.3,\n                    y: expanded ? 0 : -(i + 1) * 5,\n                    scale: 1 - (i + 1) * 0.06,\n                  }}\n                  aria-hidden=\"true\"\n                  className=\"absolute inset-0 rounded-[20px] bg-zinc-800 ring-1 ring-white/5 dark:bg-zinc-800\"\n                  initial={false}\n                  // biome-ignore lint/suspicious/noArrayIndexKey: layers are positional\n                  key={i}\n                  style={{ zIndex: -1 - i }}\n                  transition={MORPH}\n                />\n              ))}\n              <motion.div\n                className=\"relative overflow-hidden bg-zinc-950 text-white shadow-black/20 shadow-lg ring-1 ring-black/5 dark:bg-zinc-900 dark:ring-white/10\"\n                layout\n                onBlur={handleBlur}\n                onFocus={() => setOpen(true)}\n                onKeyDown={handleKeyDown}\n                onPointerEnter={handlePointerEnter}\n                onPointerLeave={handlePointerLeave}\n                ref={islandRef}\n                style={{ borderRadius: 20 }}\n                transition={MORPH}\n              >\n                {expanded ? (\n                  <motion.ul\n                    animate={{ opacity: 1, filter: \"blur(0px)\" }}\n                    className=\"flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-3.5 px-4 py-3.5\"\n                    initial={{ opacity: 0, filter: \"blur(4px)\" }}\n                    key=\"list\"\n                    layout=\"position\"\n                    transition={{ ...SWAP, delay: 0.05 }}\n                  >\n                    {tasks.map((task) => (\n                      <li key={task.id}>\n                        <TaskRow task={task} />\n                      </li>\n                    ))}\n                  </motion.ul>\n                ) : (\n                  <button\n                    aria-expanded={false}\n                    aria-label={`${describe(current)}. ${tasks.length} ${tasks.length === 1 ? \"task\" : \"tasks\"}, show all`}\n                    className=\"flex h-10 max-w-[calc(100vw-2rem)] items-center px-4 text-left focus-visible:outline-none\"\n                    onClick={() => setOpen(true)}\n                    type=\"button\"\n                  >\n                    <AnimatePresence initial={false} mode=\"popLayout\">\n                      <motion.div\n                        animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                        className=\"min-w-0\"\n                        exit={{ opacity: 0, y: -6, filter: \"blur(4px)\" }}\n                        initial={{ opacity: 0, y: 6, filter: \"blur(4px)\" }}\n                        key={`${current.id}-${current.status}`}\n                        layout=\"position\"\n                        transition={SWAP}\n                      >\n                        <div className=\"flex min-w-48 items-center gap-2.5\">\n                          <TaskIcon task={current} />\n                          <span className=\"min-w-0 flex-1 truncate text-[13px] leading-4\">\n                            {current.label}\n                            {current.detail ? (\n                              <span className=\"text-white/50\">\n                                {\" \"}\n                                · {current.detail}\n                              </span>\n                            ) : null}\n                          </span>\n                          <span className=\"pl-3\">\n                            <TaskTrailingInert task={current} />\n                          </span>\n                        </div>\n                      </motion.div>\n                    </AnimatePresence>\n                  </button>\n                )}\n              </motion.div>\n            </motion.div>\n          ) : null}\n        </AnimatePresence>\n      </div>\n    </MotionConfig>\n  );\n}\n\n/**\n * The trailing slot inside the closed pill. The pill itself is a button, so\n * the action shows as a label here and becomes a real button in the list.\n */\nfunction TaskTrailingInert({ task }: { task: IslandTask }) {\n  if (task.action) {\n    return (\n      <span className=\"-my-1 inline-block rounded-full bg-white/12 px-2.5 py-1 font-medium text-xs\">\n        {task.action.label}\n      </span>\n    );\n  }\n  return <TaskTrailing task={task} />;\n}\n\ntype DemoKind = \"upload\" | \"export\" | \"archive\" | \"sync\";\n\nconst DEMO_ACTIONS: { kind: DemoKind; label: string }[] = [\n  { kind: \"upload\", label: \"Upload photos\" },\n  { kind: \"export\", label: \"Export report\" },\n  { kind: \"archive\", label: \"Archive thread\" },\n  { kind: \"sync\", label: \"Sync calendar\" },\n];\n\n/** A playground: each button starts a pretend task that reports to the island. */\nexport default function StatusIslandDemo({\n  className,\n}: {\n  className?: string;\n}) {\n  const [tasks, setTasks] = useState<IslandTask[]>([]);\n  const timers = useRef(new Set<number>());\n  const counter = useRef(0);\n\n  useEffect(() => {\n    const active = timers.current;\n    return () => {\n      for (const timer of active) {\n        window.clearInterval(timer);\n        window.clearTimeout(timer);\n      }\n    };\n  }, []);\n\n  const update = useCallback((id: string, patch: Partial<IslandTask>) => {\n    setTasks((list) =>\n      list.map((task) => (task.id === id ? { ...task, ...patch } : task))\n    );\n  }, []);\n\n  const remove = useCallback((id: string) => {\n    setTasks((list) => list.filter((task) => task.id !== id));\n  }, []);\n\n  const later = useCallback((fn: () => void, ms: number) => {\n    const timer = window.setTimeout(() => {\n      timers.current.delete(timer);\n      fn();\n    }, ms);\n    timers.current.add(timer);\n  }, []);\n\n  const run = useCallback(\n    (\n      id: string,\n      options: { speed: number; failAt?: number; onDone: () => void }\n    ) => {\n      let progress = 0;\n      const timer = window.setInterval(() => {\n        progress = Math.min(\n          1,\n          progress + options.speed * (0.5 + Math.random())\n        );\n        if (options.failAt !== undefined && progress >= options.failAt) {\n          window.clearInterval(timer);\n          timers.current.delete(timer);\n          update(id, {\n            status: \"error\",\n            label: \"Sync failed\",\n            detail: \"Calendar offline\",\n            progress: undefined,\n            action: {\n              label: \"Retry\",\n              onClick: () => {\n                update(id, {\n                  status: \"running\",\n                  label: \"Syncing calendar\",\n                  detail: undefined,\n                  progress: 0,\n                  action: undefined,\n                });\n                run(id, { speed: options.speed, onDone: options.onDone });\n              },\n            },\n          });\n          return;\n        }\n        update(id, { progress });\n        if (progress >= 1) {\n          window.clearInterval(timer);\n          timers.current.delete(timer);\n          later(options.onDone, 250);\n        }\n      }, 180);\n      timers.current.add(timer);\n    },\n    [later, update]\n  );\n\n  const start = useCallback(\n    (kind: DemoKind) => {\n      counter.current += 1;\n      const id = `${kind}-${counter.current}`;\n      if (kind === \"archive\") {\n        setTasks((list) => [\n          ...list,\n          {\n            id,\n            status: \"done\",\n            label: \"Thread archived\",\n            action: {\n              label: \"Undo\",\n              onClick: () =>\n                update(id, {\n                  label: \"Thread restored\",\n                  action: undefined,\n                }),\n            },\n          },\n        ]);\n        return;\n      }\n      if (kind === \"upload\") {\n        setTasks((list) => [\n          ...list,\n          {\n            id,\n            status: \"running\",\n            label: \"Uploading\",\n            detail: \"12 photos\",\n            progress: 0,\n          },\n        ]);\n        run(id, {\n          speed: 0.045,\n          onDone: () =>\n            update(id, {\n              status: \"done\",\n              label: \"12 photos uploaded\",\n              detail: undefined,\n              action: { label: \"View\", onClick: () => remove(id) },\n            }),\n        });\n        return;\n      }\n      if (kind === \"export\") {\n        setTasks((list) => [\n          ...list,\n          { id, status: \"running\", label: \"Exporting report\", progress: 0 },\n        ]);\n        run(id, {\n          speed: 0.07,\n          onDone: () =>\n            update(id, {\n              status: \"done\",\n              label: \"Report ready\",\n              detail: \"Q3.csv\",\n              action: { label: \"Open\", onClick: () => remove(id) },\n            }),\n        });\n        return;\n      }\n      setTasks((list) => [\n        ...list,\n        { id, status: \"running\", label: \"Syncing calendar\", progress: 0 },\n      ]);\n      run(id, {\n        speed: 0.06,\n        failAt: counter.current % 2 === 0 ? undefined : 0.62,\n        onDone: () =>\n          update(id, {\n            status: \"done\",\n            label: \"Calendar synced\",\n            detail: undefined,\n            action: undefined,\n          }),\n      });\n    },\n    [remove, run, update]\n  );\n\n  return (\n    <div\n      className={cn(\n        \"relative flex h-[360px] w-full max-w-2xl flex-col items-center justify-between py-6\",\n        className\n      )}\n    >\n      <div className=\"flex flex-wrap justify-center gap-2 px-4\">\n        {DEMO_ACTIONS.map((action) => (\n          <button\n            className=\"h-8 rounded-full border border-zinc-200 bg-white px-3.5 font-medium text-[13px] text-zinc-700 transition-colors duration-150 hover:bg-zinc-50 hover:text-zinc-950 active:bg-zinc-100 dark:border-zinc-800 dark:bg-zinc-950 dark:text-zinc-300 dark:active:bg-zinc-800 dark:hover:bg-zinc-900 dark:hover:text-white\"\n            key={action.kind}\n            onClick={() => start(action.kind)}\n            type=\"button\"\n          >\n            {action.label}\n          </button>\n        ))}\n      </div>\n      {tasks.length === 0 ? (\n        <p className=\"text-sm text-zinc-400 dark:text-zinc-500\">\n          Start a task, then hover the island\n        </p>\n      ) : null}\n      <StatusIsland onDismiss={remove} tasks={tasks} />\n    </div>\n  );\n}\n",
      "path": "/components/kokonutui/status-island.tsx",
      "target": "components/kokonutui/status-island.tsx"
    }
  ]
}