{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "slider",
  "title": "Slider",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-svelte/components/slider/Slider.svelte",
      "content": "<script lang=\"ts\" module>\n  import type { HTMLAttributes } from 'svelte/elements'\n\n  export interface SliderMark {\n    label: string\n    style?: Record<string, string>\n  }\n\n  export type SliderValue = number | number[] | [number, number] | null | undefined\n\n  export interface SliderProps extends HTMLAttributes<HTMLDivElement> {\n    value?: SliderValue\n    defaultValue?: SliderValue\n    min?: number\n    max?: number\n    step?: number\n    orientation?: 'horizontal' | 'vertical'\n    /** Enable dual-thumb range selection */\n    range?: boolean\n    /** Vertical orientation */\n    vertical?: boolean\n    /** Height when vertical (px or css value) */\n    height?: string | number\n    /** Tick marks with labels */\n    marks?: Record<number, string | SliderMark>\n    /** Show tooltip on drag/focus. Boolean or formatter function */\n    tooltip?: boolean | ((value: number) => string)\n    /** Show dots at each step */\n    dots?: boolean\n    /** Reverse direction (right-to-left or bottom-to-top) */\n    reverse?: boolean\n    /** Highlight the track between thumbs/min (default true) */\n    included?: boolean\n    /** Size variant */\n    size?: 'small' | 'default'\n    disabled?: boolean\n    onValueChange?: (value: number | number[] | [number, number]) => void\n    onValueCommit?: (value: number | number[] | [number, number]) => void\n    ref?: HTMLDivElement | null\n  }\n</script>\n\n<script lang=\"ts\">\n  import { cn } from '$lib/utils'\n\n  let {\n    class: className,\n    value = $bindable(undefined),\n    defaultValue,\n    min = 0,\n    max = 100,\n    step = 1,\n    orientation: orientationProp,\n    range = false,\n    vertical = false,\n    height,\n    marks,\n    tooltip = true,\n    dots = false,\n    reverse = false,\n    included = true,\n    size = 'default',\n    disabled = false,\n    onValueChange,\n    onValueCommit,\n    ref = $bindable(null),\n    ...restProps\n  }: SliderProps = $props()\n\n  const orientation = $derived(vertical ? 'vertical' : (orientationProp ?? 'horizontal'))\n  const isHorizontal = $derived(orientation === 'horizontal')\n\n  // Default track matches shadcn-ish h-1.5 (aligned with Vue/React).\n  const trackSize = $derived(\n    size === 'small' ? (isHorizontal ? 'h-1' : 'w-1') : isHorizontal ? 'h-1.5' : 'w-1.5',\n  )\n  const thumbSize = $derived(size === 'small' ? 'size-3' : 'size-4')\n\n  /* ── value normalization ── */\n  function clamp(v: number) {\n    return Math.min(max, Math.max(min, v))\n  }\n\n  function snap(v: number) {\n    const snapped = Math.round((v - min) / step) * step + min\n    // Round away float dust (0.1 + 0.2) based on step precision.\n    const precision = (String(step).split('.')[1] ?? '').length\n    const factor = 10 ** precision\n    return clamp(Math.round(snapped * factor) / factor)\n  }\n\n  const thumbs = $derived.by(() => {\n    const v = value ?? defaultValue\n    const dflt = range ? [min, max] : [min]\n    if (v == null) return dflt\n    if (range) {\n      const arr = Array.isArray(v) ? v : [v, max]\n      return [clamp(arr[0] ?? min), clamp(arr[1] ?? max)]\n    }\n    return (Array.isArray(v) ? v : [v]).map((n) => clamp(n ?? min))\n  })\n\n  function emitUpdate(next: number[]) {\n    if (range) {\n      value = [next[0] ?? min, next[1] ?? max] as [number, number]\n    } else if (Array.isArray(value)) {\n      // backward-compat: consumer passed an array, emit an array\n      value = next\n    } else {\n      value = next[0] ?? min\n    }\n    onValueChange?.(value as number | number[] | [number, number])\n  }\n\n  function emitCommit(next: number[]) {\n    if (range) {\n      onValueCommit?.([next[0] ?? min, next[1] ?? max] as [number, number])\n    } else if (Array.isArray(value)) {\n      onValueCommit?.(next)\n    } else {\n      onValueCommit?.(next[0] ?? min)\n    }\n  }\n\n  function setThumb(i: number, raw: number) {\n    const next = [...thumbs]\n    const lo = i > 0 ? (next[i - 1] ?? min) : min\n    const hi = i < next.length - 1 ? (next[i + 1] ?? max) : max\n    next[i] = Math.min(hi, Math.max(lo, snap(raw)))\n    emitUpdate(next)\n    return next\n  }\n\n  /* ── pointer interaction ── */\n  let rootEl: HTMLDivElement | null = $state(null)\n  // Expose the root through `bind:ref` (bind:this accepts a single target).\n  $effect(() => {\n    ref = rootEl\n  })\n  let dragIndex = $state(-1)\n  let focusIndex = $state(-1)\n  let lastNext: number[] | null = null\n\n  function valueFromEvent(e: PointerEvent) {\n    const root = rootEl\n    if (!root) return min\n    const rect = root.getBoundingClientRect()\n    const ratio = isHorizontal\n      ? (e.clientX - rect.left) / (rect.width || 1)\n      : (rect.bottom - e.clientY) / (rect.height || 1)\n    const clampedRatio = Math.min(1, Math.max(0, ratio))\n    return reverse ? max - clampedRatio * (max - min) : min + clampedRatio * (max - min)\n  }\n\n  function nearestThumb(v: number) {\n    let best = 0\n    let bestDist = Number.POSITIVE_INFINITY\n    thumbs.forEach((t, i) => {\n      const d = Math.abs(t - v)\n      if (d < bestDist) {\n        bestDist = d\n        best = i\n      }\n    })\n    return best\n  }\n\n  function onPointerDown(e: PointerEvent) {\n    if (disabled || !rootEl) return\n    e.preventDefault()\n    rootEl.setPointerCapture(e.pointerId)\n    const v = valueFromEvent(e)\n    dragIndex = nearestThumb(v)\n    lastNext = setThumb(dragIndex, v)\n  }\n\n  function onPointerMove(e: PointerEvent) {\n    if (disabled || dragIndex < 0) return\n    e.preventDefault()\n    lastNext = setThumb(dragIndex, valueFromEvent(e))\n  }\n\n  function endDrag(e: PointerEvent) {\n    if (dragIndex < 0) return\n    dragIndex = -1\n    try {\n      rootEl?.releasePointerCapture(e.pointerId)\n    } catch {\n      /* noop — capture already released */\n    }\n    emitCommit(lastNext ?? thumbs)\n    lastNext = null\n  }\n\n  function onThumbKeyDown(e: KeyboardEvent, i: number) {\n    if (disabled) return\n    const current = thumbs[i] ?? min\n    let next: number | null = null\n    switch (e.key) {\n      case 'ArrowRight':\n      case 'ArrowUp':\n        next = current + step\n        break\n      case 'ArrowLeft':\n      case 'ArrowDown':\n        next = current - step\n        break\n      case 'PageUp':\n        next = current + step * 10\n        break\n      case 'PageDown':\n        next = current - step * 10\n        break\n      case 'Home':\n        next = min\n        break\n      case 'End':\n        next = max\n        break\n    }\n    if (next == null) return\n    e.preventDefault()\n    emitCommit(setThumb(i, next))\n  }\n\n  /* ── geometry ── */\n  const pct = (v: number) => ((v - min) / (max - min || 1)) * 100\n  // Display position flips under `reverse` so the fill grows from the trailing edge.\n  const pos = (v: number) => (reverse ? 100 - pct(v) : pct(v))\n\n  const rangeStyle = $derived.by(() => {\n    const lo = Math.min(...thumbs)\n    const hi = Math.max(...thumbs)\n    if (range || thumbs.length > 1) {\n      const a = Math.min(pos(lo), pos(hi))\n      const b = Math.max(pos(lo), pos(hi))\n      return isHorizontal ? `left:${a}%;right:${100 - b}%` : `bottom:${a}%;top:${100 - b}%`\n    }\n    const p = pos(thumbs[0] ?? min)\n    return isHorizontal\n      ? reverse\n        ? `left:${p}%;right:0`\n        : `left:0;width:${p}%`\n      : reverse\n        ? `bottom:${p}%;top:0`\n        : `bottom:0;height:${p}%`\n  })\n\n  const thumbStyle = (v: number) =>\n    isHorizontal ? `left:${pos(v)}%` : `bottom:${pos(v)}%`\n\n  /* ── marks ── */\n  const markList = $derived.by(() => {\n    if (!marks) return []\n    const entries = Object.entries(marks).map(([key, val]) => {\n      const num = Number(key)\n      const label = typeof val === 'string' ? val : val.label\n      const style = typeof val === 'string' ? undefined : val.style\n      const pctValue = ((num - min) / (max - min || 1)) * 100\n      return { value: num, label, style, pct: pctValue }\n    })\n    entries.sort((a, b) => a.value - b.value)\n    return entries\n  })\n\n  /* ── step dots ── */\n  const dotList = $derived.by(() => {\n    if (!dots) return [] as number[]\n    const list: number[] = []\n    const count = Math.floor((max - min) / step)\n    for (let i = 0; i <= count; i++) {\n      list.push(min + i * step)\n    }\n    return list\n  })\n\n  /* ── tooltip ── */\n  const showTooltip = $derived(tooltip !== false)\n\n  function formatTooltip(v: number) {\n    if (typeof tooltip === 'function') return tooltip(v)\n    return String(v)\n  }\n\n  const wrapperStyle = $derived(\n    !isHorizontal && height ? `height:${typeof height === 'number' ? `${height}px` : height}` : undefined,\n  )\n</script>\n\n<div class={cn('relative w-full', !isHorizontal && 'flex flex-col items-center')} style={wrapperStyle}>\n  <div\n    bind:this={rootEl}\n    data-uipkge=\"\"\n    data-slot=\"slider\"\n    data-orientation={orientation}\n    data-disabled={disabled ? '' : undefined}\n    class={cn(\n      'relative flex touch-none select-none data-[disabled]:opacity-50',\n      isHorizontal ? 'w-full items-center' : 'h-full min-h-44 flex-col justify-center',\n      className,\n    )}\n    onpointerdown={onPointerDown}\n    onpointermove={onPointerMove}\n    onpointerup={endDrag}\n    onpointercancel={endDrag}\n    {...restProps}\n  >\n    <!-- Track -->\n    <div\n      data-uipkge=\"\"\n      data-slot=\"slider-track\"\n      class={cn('bg-muted relative grow overflow-hidden rounded-full', trackSize)}\n    >\n      {#if included}\n        <div\n          data-uipkge=\"\"\n          data-slot=\"slider-range\"\n          class={cn('bg-primary absolute', isHorizontal ? 'h-full' : 'w-full')}\n          style={rangeStyle}\n        ></div>\n      {/if}\n    </div>\n\n    <!-- Step dots -->\n    {#each dotList as dot (dot)}\n      <div\n        class={cn(\n          'border-primary/40 bg-background absolute rounded-full border',\n          isHorizontal ? 'top-1/2 size-1.5 -translate-y-1/2' : 'left-1/2 size-1.5 -translate-x-1/2',\n          size === 'small' && 'size-1',\n        )}\n        style={isHorizontal\n          ? `left:${((dot - min) / (max - min || 1)) * 100}%;transform:translateX(-50%) translateY(-50%)`\n          : `bottom:${((dot - min) / (max - min || 1)) * 100}%;transform:translateX(-50%) translateY(50%)`}\n      ></div>\n    {/each}\n\n    <!-- Marks -->\n    {#if markList.length}\n      <div\n        class={cn(\n          'pointer-events-none absolute',\n          isHorizontal ? 'top-full mt-2.5 h-5 w-full' : 'top-0 left-full ml-3 h-full w-20',\n        )}\n      >\n        {#each markList as mark (mark.value)}\n          {@const markStyle = Object.entries(mark.style ?? {})\n            .map(([k, v]) => `${k}:${v}`)\n            .join(';')}\n          <span\n            class=\"text-muted-foreground absolute text-xs whitespace-nowrap\"\n            style=\"{markStyle}{markStyle ? ';' : ''}{isHorizontal\n              ? `left:${mark.pct}%;transform:translateX(-50%)`\n              : `bottom:${mark.pct}%;transform:translateY(50%)`}\"\n          >\n            {mark.label}\n          </span>\n        {/each}\n      </div>\n    {/if}\n\n    <!-- Thumbs (with tooltips) -->\n    {#each thumbs as thumbValue, idx (idx)}\n      <span\n        class={cn(\n          'group/slider-thumb absolute flex items-center justify-center',\n          isHorizontal ? 'top-1/2 -translate-x-1/2 -translate-y-1/2' : 'left-1/2 -translate-x-1/2 translate-y-1/2',\n        )}\n        style={thumbStyle(thumbValue)}\n      >\n        <div\n          role=\"slider\"\n          tabindex={disabled ? -1 : 0}\n          aria-valuemin={min}\n          aria-valuemax={max}\n          aria-valuenow={thumbValue}\n          aria-orientation={orientation}\n          aria-disabled={disabled || undefined}\n          data-uipkge=\"\"\n          data-slot=\"slider-thumb\"\n          data-disabled={disabled ? '' : undefined}\n          class={cn(\n            'border-primary bg-background ring-ring/50 block shrink-0 touch-manipulation rounded-full border shadow-sm hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50 data-[disabled]:pointer-events-none',\n            thumbSize,\n          )}\n          onkeydown={(e) => onThumbKeyDown(e, idx)}\n          onfocus={() => (focusIndex = idx)}\n          onblur={() => (focusIndex = -1)}\n        ></div>\n        {#if showTooltip}\n          <span\n            class={cn(\n              'bg-foreground text-background pointer-events-none absolute z-50 w-fit rounded-md px-2 py-1 text-xs whitespace-nowrap opacity-0 transition-opacity group-hover/slider-thumb:opacity-100 group-focus-visible/slider-thumb:opacity-100',\n              isHorizontal ? 'bottom-full left-1/2 mb-2 -translate-x-1/2' : 'top-1/2 left-full ml-2 -translate-y-1/2',\n              (dragIndex === idx || focusIndex === idx) && 'opacity-100',\n            )}\n          >\n            {formatTooltip(thumbValue)}\n          </span>\n        {/if}\n      </span>\n    {/each}\n  </div>\n</div>\n\n<style>\n  /*\n    Subtle motion suite for slider:\n    - Range fill + thumb position ease on keyboard / programmatic steps\n    - Press scale on thumb (CSS `scale`, not `transform` — primitives set transform inline)\n    - While the root is :active (pointer drag / track scrub), drop positional transitions so the\n      thumb and fill track the pointer 1:1 with no lag\n    - prefers-reduced-motion kills all of it\n  */\n  @media (prefers-reduced-motion: no-preference) {\n    [data-slot='slider-range'] {\n      transition-property: left, right, top, bottom;\n      transition-duration: 150ms;\n      transition-timing-function: cubic-bezier(0.16, 1, 0.3, 1);\n    }\n\n    [data-slot='slider-thumb'] {\n      scale: 1;\n      transition-property: color, box-shadow, border-color, scale, left, right, top, bottom;\n      transition-duration: 150ms;\n      transition-timing-function: cubic-bezier(0.16, 1, 0.3, 1);\n    }\n\n    [data-slot='slider-thumb']:active:not([data-disabled]) {\n      scale: 0.94;\n      transition-duration: 100ms;\n    }\n\n    /* Instant follow while pointer is down — keyboard still gets the ease above */\n    [data-slot='slider']:active [data-slot='slider-range'],\n    [data-slot='slider']:active [data-slot='slider-thumb'] {\n      transition-property: color, box-shadow, border-color, scale;\n      transition-duration: 100ms;\n    }\n  }\n\n  @media (prefers-reduced-motion: reduce) {\n    [data-slot='slider-range'],\n    [data-slot='slider-thumb'] {\n      transition: none !important;\n      scale: 1 !important;\n    }\n  }\n</style>\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/slider/Slider.svelte"
    },
    {
      "path": "packages/registry-svelte/components/slider/index.ts",
      "content": "export { default as Slider, type SliderMark, type SliderProps, type SliderValue } from './Slider.svelte'\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/slider/index.ts"
    }
  ],
  "dependencies": [],
  "devDependencies": [],
  "registryDependencies": [],
  "description": "Single-thumb slider — pick a value within a range. Optional tick marks, step size, and inline value display.",
  "categories": [
    "form"
  ]
}