{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "sliding-list",
  "title": "Sliding List",
  "description": "A controlled or uncontrolled vertical list with optional initial selection, mirrored alignment, and a dot, dash, or custom icon indicator.",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/base/ui/sliding-list.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { motion, useReducedMotion, type Transition } from \"motion/react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport type SlidingListAlignment = \"left\" | \"right\";\nexport type SlidingListIndicator = \"dot\" | \"dash\" | React.ReactElement;\n\nexport type SlidingListItem = {\n  value: string;\n  label: React.ReactNode;\n  disabled?: boolean;\n  id?: string;\n  ariaControls?: string;\n};\n\nexport type SlidingListProps = Omit<\n  React.ComponentProps<\"div\">,\n  \"align\" | \"defaultValue\" | \"onChange\"\n> & {\n  items: SlidingListItem[];\n  /** Controlled active value. Pass null to render without an active item. */\n  value?: string | null;\n  /** Initial active value. Omit or pass null to start without an active item. */\n  defaultValue?: string | null;\n  onValueChange?: (value: string, item: SlidingListItem) => void;\n  align?: SlidingListAlignment;\n  /** A built-in dot or dash, or any custom React node such as an icon. */\n  indicator?: SlidingListIndicator;\n  listClassName?: string;\n  itemClassName?: string;\n  labelClassName?: string;\n  indicatorClassName?: string;\n  onItemPointerEnter?: (item: SlidingListItem) => void;\n  onItemFocus?: (item: SlidingListItem) => void;\n  \"aria-label\"?: string;\n};\n\nconst SLIDE_TRANSITION: Transition = {\n  duration: 0.22,\n  ease: [0.65, 0, 0.35, 1],\n};\n\nfunction getEnabledItems(items: SlidingListItem[]) {\n  return items.filter((item) => !item.disabled);\n}\n\nfunction getSelectableValue(\n  items: SlidingListItem[],\n  value: string | null | undefined,\n) {\n  const enabledItems = getEnabledItems(items);\n\n  if (value == null) return null;\n\n  return enabledItems.some((item) => item.value === value)\n    ? value\n    : null;\n}\n\nexport function SlidingList({\n  items,\n  value,\n  defaultValue,\n  onValueChange,\n  align = \"left\",\n  indicator = \"dot\",\n  className,\n  listClassName,\n  itemClassName,\n  labelClassName,\n  indicatorClassName,\n  onItemPointerEnter,\n  onItemFocus,\n  \"aria-label\": ariaLabel = \"Options\",\n  ...props\n}: SlidingListProps) {\n  const shouldReduceMotion = useReducedMotion();\n  const isControlled = value !== undefined;\n  const itemRefs = React.useRef(new Map<string, HTMLButtonElement>());\n  const [uncontrolledValue, setUncontrolledValue] = React.useState(\n    () => getSelectableValue(items, defaultValue),\n  );\n\n  const enabledItems = React.useMemo(() => getEnabledItems(items), [items]);\n  const selectedValue =\n    getSelectableValue(items, isControlled ? value : uncontrolledValue);\n  const focusableValue = selectedValue ?? enabledItems[0]?.value ?? null;\n\n  const setSelectedValue = React.useCallback(\n    (nextValue: string) => {\n      const nextItem = items.find((item) => item.value === nextValue);\n\n      if (!nextItem || nextItem.disabled || nextValue === selectedValue) {\n        return;\n      }\n\n      if (!isControlled) {\n        setUncontrolledValue(nextValue);\n      }\n\n      onValueChange?.(nextValue, nextItem);\n    },\n    [isControlled, items, onValueChange, selectedValue],\n  );\n\n  const focusItem = React.useCallback((nextValue: string) => {\n    itemRefs.current.get(nextValue)?.focus();\n  }, []);\n\n  const handleKeyDown = React.useCallback(\n    (\n      event: React.KeyboardEvent<HTMLButtonElement>,\n      currentValue: string,\n    ) => {\n      if (enabledItems.length === 0) return;\n\n      const currentIndex = Math.max(\n        enabledItems.findIndex((item) => item.value === currentValue),\n        0,\n      );\n      const lastIndex = enabledItems.length - 1;\n      let nextIndex = currentIndex;\n\n      if (event.key === \"ArrowDown\" || event.key === \"ArrowRight\") {\n        nextIndex = currentIndex >= lastIndex ? 0 : currentIndex + 1;\n      } else if (event.key === \"ArrowUp\" || event.key === \"ArrowLeft\") {\n        nextIndex = currentIndex <= 0 ? lastIndex : currentIndex - 1;\n      } else if (event.key === \"Home\") {\n        nextIndex = 0;\n      } else if (event.key === \"End\") {\n        nextIndex = lastIndex;\n      } else {\n        return;\n      }\n\n      event.preventDefault();\n\n      const nextItem = enabledItems[nextIndex];\n\n      if (!nextItem) return;\n\n      setSelectedValue(nextItem.value);\n      focusItem(nextItem.value);\n    },\n    [enabledItems, focusItem, setSelectedValue],\n  );\n\n  const transition = shouldReduceMotion\n    ? ({ duration: 0 } as const)\n    : SLIDE_TRANSITION;\n  const isCustomIndicator = indicator !== \"dot\" && indicator !== \"dash\";\n  const activeOffsetDistance = isCustomIndicator ? 20 : 16;\n  const activeOffset =\n    align === \"left\" ? activeOffsetDistance : -activeOffsetDistance;\n\n  return (\n    <div\n      {...props}\n      data-slot=\"sliding-list\"\n      data-align={align}\n      className={cn(\"flex w-full\", className)}\n    >\n      <ul\n        role=\"tablist\"\n        aria-label={ariaLabel}\n        aria-orientation=\"vertical\"\n        data-slot=\"sliding-list-list\"\n        data-align={align}\n        className={cn(\"flex w-full flex-col gap-1\", listClassName)}\n      >\n        {items.map((item) => {\n          const isActive = selectedValue === item.value;\n\n          return (\n            <li key={item.value} role=\"presentation\" className=\"w-full\">\n              <button\n                id={item.id}\n                type=\"button\"\n                role=\"tab\"\n                aria-selected={isActive}\n                aria-controls={item.ariaControls}\n                disabled={item.disabled}\n                tabIndex={item.value === focusableValue ? 0 : -1}\n                ref={(element) => {\n                  if (element) {\n                    itemRefs.current.set(item.value, element);\n                  } else {\n                    itemRefs.current.delete(item.value);\n                  }\n                }}\n                data-slot=\"sliding-list-trigger\"\n                data-active={isActive ? \"\" : undefined}\n                onClick={() => setSelectedValue(item.value)}\n                onKeyDown={(event) => handleKeyDown(event, item.value)}\n                onPointerEnter={() => onItemPointerEnter?.(item)}\n                onFocus={() => onItemFocus?.(item)}\n                className={cn(\n                  \"flex min-h-9 w-full items-center rounded-md px-2 py-1.5 text-sm font-medium text-muted-foreground outline-none transition-colors duration-150 ease-out hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background motion-reduce:transition-none disabled:pointer-events-none disabled:opacity-50 data-active:text-foreground\",\n                  align === \"left\"\n                    ? \"justify-start text-left\"\n                    : \"justify-end text-right\",\n                  itemClassName,\n                )}\n              >\n                <motion.span\n                  initial={false}\n                  // Independent transform values (not a composed `transform`\n                  // string), so Motion can use its hardware-accelerated path\n                  // instead of interpolating a string on the main thread.\n                  animate={{ x: isActive ? activeOffset : 0 }}\n                  transition={transition}\n                  className=\"relative inline-flex items-center will-change-transform\"\n                >\n                  <motion.span\n                    aria-hidden=\"true\"\n                    data-slot=\"sliding-list-indicator\"\n                    data-indicator={\n                      indicator === \"dot\" || indicator === \"dash\"\n                        ? indicator\n                        : \"custom\"\n                    }\n                    initial={false}\n                    animate={{\n                      opacity: isActive ? 1 : 0,\n                      y: \"-50%\",\n                      scale: isActive ? 1 : 0.5,\n                    }}\n                    transition={transition}\n                    className={cn(\n                      \"absolute top-1/2 inline-flex size-3.5 items-center justify-center [&_svg]:size-3.5\",\n                      align === \"left\"\n                        ? isCustomIndicator\n                          ? \"-left-5\"\n                          : \"-left-4\"\n                        : isCustomIndicator\n                          ? \"-right-5\"\n                          : \"-right-4\",\n                      indicatorClassName,\n                    )}\n                  >\n                    {indicator === \"dot\" ? (\n                      <span className=\"size-1 rounded-full bg-current\" />\n                    ) : indicator === \"dash\" ? (\n                      <span className=\"h-px w-2.5 bg-current\" />\n                    ) : (\n                      indicator\n                    )}\n                  </motion.span>\n                  <span className={cn(\"relative\", labelClassName)}>\n                    {item.label}\n                  </span>\n                </motion.span>\n              </button>\n            </li>\n          );\n        })}\n      </ul>\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/sliding-list.tsx"
    }
  ],
  "meta": {
    "tags": [
      "selectable-list",
      "tab-list",
      "roving-focus",
      "alignment",
      "indicator",
      "icon",
      "controlled",
      "uncontrolled",
      "optional-selection",
      "list"
    ],
    "effects": [
      "active-shift",
      "indicator-reveal",
      "reduced-motion"
    ]
  },
  "categories": [
    "navigation"
  ],
  "type": "registry:ui"
}