{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion","lucide-react"],"description":"A list where selected items pin to the top and animate out of the flow into a pinned section.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { Pin } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport type { KeyboardEvent, ReactNode } from \"react\";\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\n\nconst DEFAULT_MAX_PINNED = 5;\n\n/* -------------------------------------------------------------------------- */\n/* Motion tokens                                                              */\n/* -------------------------------------------------------------------------- */\n\nconst EASE_OUT: [number, number, number, number] = [0.23, 1, 0.32, 1];\nconst INSTANT = { duration: 0 };\n/** Entrance/exit of a brand-new row — restrained. */\nconst ENTER_SPRING = { bounce: 0.1, duration: 0.25, type: \"spring\" as const };\n/** Leaving is the user's decision already made — get out of the way faster. */\nconst EXIT_TRANSITION = { duration: 0.15, ease: EASE_OUT };\n/** The row launching into (or back out of) the pinned group — real overshoot. */\nconst LAUNCH_SPRING = { bounce: 0.3, duration: 0.4, type: \"spring\" as const };\n/** The pin icon's own, snappier spring. */\nconst PIN_SPRING = { bounce: 0.32, duration: 0.2, type: \"spring\" as const };\n/** Rows closing the gap settle one after the other instead of as a rigid block. */\nconst STAGGER_STEP = 0.025;\nconst MAX_STAGGER = 0.1;\n/**\n * The landing pop, as a damped squash rather than an inflate. A row that grows\n * past 1 pushes outside the list's box, and the list almost always sits inside\n * a scroller — so the pop got sliced off exactly when it was biggest. Every\n * keyframe here stays at or below 1, so the row can never breach its own\n * bounds no matter how wide it is; the compression settles in two decaying\n * steps, which is how weight actually lands.\n */\nconst POP_SCALE = [1, 0.985, 0.997, 1];\nconst POP_TIMES = [0, 0.3, 0.62, 1];\nconst POP_DURATION = 0.34;\nconst POP_RESET_MS = 420;\nconst PINNED_ROTATION = 45;\n/**\n * Rows never sit flush against the list's edge: 4px of padding keeps the\n * landing rebound, the launch spring's overshoot and the 4px focus ring\n * (2px ring + 2px offset) inside whatever clips this list.\n */\nconst ROW_HALO = \"p-1\";\n\nexport type PinnedListItem = {\n  icon?: ReactNode;\n  id: string;\n  meta?: string;\n  pinned?: boolean;\n  title: string;\n};\n\nexport type PinnedListProps = {\n  className?: string;\n  emptyPinnedMessage?: string;\n  items: PinnedListItem[];\n  /** Names the list for assistive technology. */\n  label?: string;\n  maxPinned?: number;\n  onPinnedChange?: (ids: string[]) => void;\n  pinnedIds?: string[];\n  renderItem?: (item: PinnedListItem, isPinned: boolean) => ReactNode;\n  showDivider?: boolean;\n};\n\n/* -------------------------------------------------------------------------- */\n/* Row                                                                       */\n/* -------------------------------------------------------------------------- */\n\ntype RowProps = {\n  groupIndex: number;\n  isFocused: boolean;\n  isPinned: boolean;\n  item: PinnedListItem;\n  justPinned: boolean;\n  onKeyDownRow: (event: KeyboardEvent<HTMLDivElement>, index: number) => void;\n  onTogglePin: (id: string) => void;\n  pinDisabled: boolean;\n  registerRef: (id: string, el: HTMLDivElement | null) => void;\n  renderItem?: (item: PinnedListItem, isPinned: boolean) => ReactNode;\n  rowIndex: number;\n  shouldReduceMotion: boolean;\n};\n\nconst Row = ({\n  groupIndex,\n  isFocused,\n  isPinned,\n  item,\n  justPinned,\n  onKeyDownRow,\n  onTogglePin,\n  pinDisabled,\n  registerRef,\n  renderItem,\n  rowIndex,\n  shouldReduceMotion,\n}: RowProps) => {\n  // The row that was just acted on leads; the rows closing the gap trail it.\n  const layoutDelay = isPinned\n    ? 0\n    : Math.min(groupIndex * STAGGER_STEP, MAX_STAGGER);\n\n  return (\n    <motion.li\n      className=\"list-none\"\n      layout={shouldReduceMotion ? false : \"position\"}\n      transition={\n        shouldReduceMotion\n          ? INSTANT\n          : {\n              default: ENTER_SPRING,\n              layout: { ...LAUNCH_SPRING, delay: layoutDelay },\n            }\n      }\n    >\n      {/* The row itself is the control: it carries the roving tabindex, so\n          Arrow/Home/End/P all reach a handler that is actually focused. */}\n      <motion.div\n        animate={\n          justPinned && !shouldReduceMotion\n            ? { scale: POP_SCALE }\n            : { scale: 1 }\n        }\n        aria-disabled={pinDisabled || undefined}\n        aria-label={`${isPinned ? \"Unpin\" : \"Pin\"} ${item.title}`}\n        aria-pressed={isPinned}\n        className={cn(\n          \"group relative flex items-center gap-3 rounded-xl border p-3 outline-none\",\n          \"transition-[background-color,border-color,box-shadow] duration-200 ease-out\",\n          \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n          // Pinned rows are raised, not tinted: a solid surface lifted off the\n          // list with a real shadow and a crisper edge, the way the rest of the\n          // library marks a selected surface. Dark mode lifts with light\n          // instead, because a shadow has nothing to fall on there.\n          isPinned\n            ? \"border-foreground/15 bg-background shadow-black/[0.07] shadow-sm dark:border-foreground/20 dark:bg-foreground/[0.07] dark:shadow-none\"\n            : \"border-foreground/[0.08] bg-transparent\",\n          pinDisabled\n            ? \"cursor-not-allowed opacity-60\"\n            : \"cursor-pointer hover:border-foreground/20\"\n        )}\n        initial={false}\n        onClick={() => {\n          if (!pinDisabled) {\n            onTogglePin(item.id);\n          }\n        }}\n        onKeyDown={(event) => onKeyDownRow(event, rowIndex)}\n        ref={(el) => {\n          registerRef(item.id, el);\n        }}\n        role=\"button\"\n        tabIndex={isFocused ? 0 : -1}\n        transition={\n          shouldReduceMotion\n            ? INSTANT\n            : { duration: POP_DURATION, ease: EASE_OUT, times: POP_TIMES }\n        }\n      >\n        {/* The only piece of brand colour on the row: a short rail on the\n            leading edge. It grows out of nothing on pin, so the state change\n            is structural rather than a rectangle of fill appearing. */}\n        <motion.span\n          animate={{\n            opacity: isPinned ? 1 : 0,\n            scaleY: isPinned ? 1 : 0.2,\n          }}\n          aria-hidden=\"true\"\n          className=\"absolute top-1/2 left-0 h-5 w-[3px] rounded-r-full bg-brand\"\n          initial={false}\n          style={{ y: \"-50%\" }}\n          transition={shouldReduceMotion ? INSTANT : PIN_SPRING}\n        />\n        <div className=\"min-w-0 flex-1\">\n          {renderItem ? (\n            renderItem(item, isPinned)\n          ) : (\n            <div className=\"flex items-center gap-2\">\n              {item.icon ? (\n                <span\n                  aria-hidden=\"true\"\n                  className={cn(\n                    \"shrink-0 transition-colors duration-200\",\n                    isPinned ? \"text-foreground\" : \"text-foreground/45\"\n                  )}\n                >\n                  {item.icon}\n                </span>\n              ) : null}\n              <div className=\"min-w-0\">\n                <p className=\"truncate font-medium text-foreground text-sm\">\n                  {item.title}\n                </p>\n                {item.meta ? (\n                  <p className=\"truncate text-muted-foreground text-xs\">\n                    {item.meta}\n                  </p>\n                ) : null}\n              </div>\n            </div>\n          )}\n        </div>\n        <span\n          aria-hidden=\"true\"\n          className={cn(\n            \"flex size-8 shrink-0 items-center justify-center rounded-full transition-colors duration-200\",\n            isPinned\n              ? \"text-brand\"\n              : \"text-foreground/40 group-hover:bg-foreground/5 group-hover:text-foreground\"\n          )}\n        >\n          <motion.span\n            animate={{ rotate: isPinned ? PINNED_ROTATION : 0 }}\n            className=\"flex items-center justify-center\"\n            initial={false}\n            transition={shouldReduceMotion ? INSTANT : PIN_SPRING}\n          >\n            <Pin className={cn(\"size-4\", isPinned && \"fill-current\")} />\n          </motion.span>\n        </span>\n      </motion.div>\n    </motion.li>\n  );\n};\n\n/* -------------------------------------------------------------------------- */\n/* Root                                                                      */\n/* -------------------------------------------------------------------------- */\n\nconst PinnedList = ({\n  className,\n  emptyPinnedMessage = \"Pin an item to keep it at the top.\",\n  items,\n  label = \"Pinnable items\",\n  maxPinned = DEFAULT_MAX_PINNED,\n  onPinnedChange,\n  pinnedIds,\n  renderItem,\n  showDivider = true,\n}: PinnedListProps) => {\n  const shouldReduceMotion = Boolean(useReducedMotion());\n  const [internalPinned, setInternalPinned] = useState<string[]>(() =>\n    items.filter((item) => item.pinned).map((item) => item.id)\n  );\n  const [focusedId, setFocusedId] = useState<string | null>(null);\n  const [justPinnedId, setJustPinnedId] = useState<string | null>(null);\n  const rowRefs = useRef(new Map<string, HTMLDivElement>());\n  // A row that moves between the two groups is re-inserted in the DOM, which\n  // can drop focus. Remember which row to hand focus back to after the move.\n  const restoreFocusRef = useRef<string | null>(null);\n\n  const isControlled = pinnedIds !== undefined;\n  const pinned = isControlled ? pinnedIds : internalPinned;\n\n  useEffect(() => {\n    if (!justPinnedId) {\n      return;\n    }\n    const timer = setTimeout(() => setJustPinnedId(null), POP_RESET_MS);\n    return () => clearTimeout(timer);\n  }, [justPinnedId]);\n\n  const pinnedItems = useMemo(\n    () => items.filter((item) => pinned.includes(item.id)),\n    [items, pinned]\n  );\n  const unpinnedItems = useMemo(\n    () => items.filter((item) => !pinned.includes(item.id)),\n    [items, pinned]\n  );\n  const orderedItems = useMemo(\n    () => [...pinnedItems, ...unpinnedItems],\n    [pinnedItems, unpinnedItems]\n  );\n\n  // Exactly one row is tabbable at a time, and it is always a row that exists.\n  const activeFocusId =\n    focusedId && orderedItems.some((item) => item.id === focusedId)\n      ? focusedId\n      : (orderedItems[0]?.id ?? null);\n\n  const registerRef = useCallback((id: string, el: HTMLDivElement | null) => {\n    if (el) {\n      rowRefs.current.set(id, el);\n    } else {\n      rowRefs.current.delete(id);\n    }\n  }, []);\n\n  const setPinned = (next: string[]) => {\n    if (!isControlled) {\n      setInternalPinned(next);\n    }\n    onPinnedChange?.(next);\n  };\n\n  const togglePin = (id: string) => {\n    // Keep focus on the row the user acted on, even though pinning moves it.\n    if (rowRefs.current.get(id) === document.activeElement) {\n      restoreFocusRef.current = id;\n    }\n    setFocusedId(id);\n\n    if (pinned.includes(id)) {\n      setJustPinnedId(null);\n      setPinned(pinned.filter((pinnedId) => pinnedId !== id));\n      return;\n    }\n    if (pinned.length >= maxPinned) {\n      return;\n    }\n    setJustPinnedId(id);\n    setPinned([...pinned, id]);\n  };\n\n  useEffect(() => {\n    const id = restoreFocusRef.current;\n    if (!id) {\n      return;\n    }\n    restoreFocusRef.current = null;\n    rowRefs.current.get(id)?.focus();\n  });\n\n  const focusRowAt = (index: number) => {\n    const total = orderedItems.length;\n    if (total === 0) {\n      return;\n    }\n    const nextItem = orderedItems[((index % total) + total) % total];\n    setFocusedId(nextItem.id);\n    rowRefs.current.get(nextItem.id)?.focus();\n  };\n\n  const handleKeyDownRow = (\n    event: KeyboardEvent<HTMLDivElement>,\n    index: number\n  ) => {\n    if (event.key === \"ArrowDown\" || event.key === \"ArrowRight\") {\n      event.preventDefault();\n      focusRowAt(index + 1);\n      return;\n    }\n    if (event.key === \"ArrowUp\" || event.key === \"ArrowLeft\") {\n      event.preventDefault();\n      focusRowAt(index - 1);\n      return;\n    }\n    if (event.key === \"Home\") {\n      event.preventDefault();\n      focusRowAt(0);\n      return;\n    }\n    if (event.key === \"End\") {\n      event.preventDefault();\n      focusRowAt(orderedItems.length - 1);\n      return;\n    }\n    if (\n      event.key === \"p\" ||\n      event.key === \"P\" ||\n      event.key === \"Enter\" ||\n      event.key === \" \"\n    ) {\n      event.preventDefault();\n      const item = orderedItems[index];\n      if (item) {\n        togglePin(item.id);\n      }\n    }\n  };\n\n  const pinDisabled = pinned.length >= maxPinned;\n  const showEmptyPinned = pinnedItems.length === 0;\n  const showSeparator =\n    showDivider && pinnedItems.length > 0 && unpinnedItems.length > 0;\n\n  return (\n    <div className={cn(\"flex flex-col\", className)}>\n      {/* One list, not two: a pinned row is reordered inside its own parent\n          instead of being torn out of one list and rebuilt in another. */}\n      <ul aria-label={label} className={cn(\"flex flex-col gap-2\", ROW_HALO)}>\n        {/* popLayout takes the leaving placeholder out of flow on the first\n            frame, so the rows above it move as one — and, because nothing is\n            left waiting on a layout animation, its exit actually completes. */}\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {showEmptyPinned ? (\n            <motion.li\n              animate={{ opacity: 1 }}\n              className=\"list-none rounded-xl border border-foreground/15 border-dashed p-3 text-muted-foreground text-sm\"\n              exit={\n                shouldReduceMotion\n                  ? { opacity: 0, transition: INSTANT }\n                  : { opacity: 0, transition: EXIT_TRANSITION }\n              }\n              initial={shouldReduceMotion ? { opacity: 1 } : { opacity: 0 }}\n              key=\"empty-pinned\"\n              transition={shouldReduceMotion ? INSTANT : ENTER_SPRING}\n            >\n              {emptyPinnedMessage}\n            </motion.li>\n          ) : null}\n        </AnimatePresence>\n\n        {pinnedItems.map((item, groupIndex) => (\n          <Row\n            groupIndex={groupIndex}\n            isFocused={activeFocusId === item.id}\n            isPinned\n            item={item}\n            justPinned={justPinnedId === item.id}\n            key={item.id}\n            onKeyDownRow={handleKeyDownRow}\n            onTogglePin={togglePin}\n            pinDisabled={false}\n            registerRef={registerRef}\n            renderItem={renderItem}\n            rowIndex={groupIndex}\n            shouldReduceMotion={shouldReduceMotion}\n          />\n        ))}\n\n        {/* A hairline is below the threshold worth animating; it also keeps\n            the exiting-child machinery off the one element in the list that\n            has no content of its own. */}\n        {showSeparator ? (\n          <li\n            aria-hidden=\"true\"\n            className=\"my-1 list-none border-foreground/10 border-t\"\n          />\n        ) : null}\n\n        {unpinnedItems.map((item, groupIndex) => (\n          <Row\n            groupIndex={groupIndex}\n            isFocused={activeFocusId === item.id}\n            isPinned={false}\n            item={item}\n            justPinned={false}\n            key={item.id}\n            onKeyDownRow={handleKeyDownRow}\n            onTogglePin={togglePin}\n            pinDisabled={pinDisabled}\n            registerRef={registerRef}\n            renderItem={renderItem}\n            rowIndex={pinnedItems.length + groupIndex}\n            shouldReduceMotion={shouldReduceMotion}\n          />\n        ))}\n      </ul>\n    </div>\n  );\n};\n\nexport default PinnedList;\n","path":"index.tsx","target":"components/smoothui/pinned-list/index.tsx","type":"registry:ui"}],"name":"pinned-list","registryDependencies":["https://smoothui.dev/r/tokens.json"],"title":"Pinned List","type":"registry:ui"}