curio.
no. 17 · sliders

Ruler slider

Scrub a strip of tick marks under a fixed marker.

Preview
SunriseDayOvercastDuskMidnight

Install

npx shadcn@latest add https://curio-components.vercel.app/r/ruler-slider.json

First curio component? Also add @import "./curio.css"; to your global CSS. See installation.

Usage

'use client'

import { useState } from 'react'
import { RulerSlider } from '@/components/curio/sliders/ruler-slider'

const themes = [
  { label: 'Sunrise', swatch: '#fff4e6', accent: '#ea580c' },
  { label: 'Day', swatch: '#ffffff', accent: '#18181b' },
  { label: 'Midnight', swatch: '#09090b', accent: '#fafafa' },
]

export function ThemePicker() {
  const [value, setValue] = useState(0)
  return <RulerSlider options={themes} value={value} onChange={setValue} />
}

Props

PropTypeDefaultDescription
optionsThemeOption[]-Each step: label, swatch?, accent?
valuenumber-Index of the selected option.
onChange(index: number) => void-Called with the new index.
labelstring"theme"Accessible name.
gapnumber44Px between options.
widthnumber150Visible width.

Source

This is exactly what the command copies into your project.

components/curio/sliders/ruler-slider.tsx
'use client'

import { useEffect, useRef, useState } from 'react'
import { clampTo, sliderAria, sliderKeys, type SliderProps } from '../lib/slider'

// drag a strip of tick marks under a fixed marker; tap either side to step
export function RulerSlider(props: SliderProps & { gap?: number; width?: number }) {
  const { options, value, onChange, gap = 44, width = 150 } = props
  const count = options.length
  const [drag, setDrag] = useState(0)
  const [dragging, setDragging] = useState(false)
  const [settled, setSettled] = useState(false) // no slide-in on first render
  const startX = useRef(0)
  const startValue = useRef(0)

  useEffect(() => {
    const id = requestAnimationFrame(() => setSettled(true))
    return () => cancelAnimationFrame(id)
  }, [])

  const offset = width / 2 - value * gap + drag
  const fade = 'linear-gradient(90deg, transparent, black 25%, black 75%, transparent)'

  return (
    <div
      {...sliderAria(props)}
      onKeyDown={sliderKeys(value, count, onChange)}
      onPointerDown={(e) => {
        e.currentTarget.setPointerCapture(e.pointerId)
        startX.current = e.clientX
        startValue.current = value
        setDragging(true)
      }}
      onPointerMove={(e) => {
        if (!dragging) return
        const dx = e.clientX - startX.current
        const next = clampTo(startValue.current - Math.round(dx / gap), count)
        setDrag(dx + (next - startValue.current) * gap)
        if (next !== value) onChange(next)
      }}
      onPointerUp={(e) => {
        if (Math.abs(e.clientX - startX.current) < 4) {
          const rect = e.currentTarget.getBoundingClientRect()
          onChange(clampTo(value + (e.clientX - rect.left < width / 2 ? -1 : 1), count))
        }
        setDragging(false)
        setDrag(0)
      }}
      onPointerCancel={() => {
        setDragging(false)
        setDrag(0)
      }}
      className="relative h-10 shrink-0 cursor-grab touch-none select-none overflow-hidden outline-none active:cursor-grabbing"
      style={{ width, maskImage: fade, WebkitMaskImage: fade }}
    >
      <div
        className="absolute top-0 h-full"
        style={{ transform: `translateX(${offset}px)`, transition: dragging || !settled ? 'none' : 'transform 350ms cubic-bezier(.23,1,.32,1)' }}
      >
        {Array.from({ length: (count - 1) * 4 + 1 }, (_, i) => {
          const major = i % 4 === 0
          return (
            <span
              key={i}
              className="absolute top-1.5 w-px -translate-x-1/2"
              style={{ left: (i * gap) / 4, height: major ? 11 : 5, background: 'var(--curio-muted)', opacity: major ? 0.9 : 0.45 }}
            />
          )
        })}
        {options.map((o, i) => (
          <span
            key={o.label}
            className="absolute top-[22px] -translate-x-1/2 text-[11px] transition-colors"
            style={{ left: i * gap, color: i === value ? 'var(--curio-fg)' : 'var(--curio-muted)' }}
          >
            {o.label}
          </span>
        ))}
      </div>
      <span className="absolute left-1/2 top-0 h-[22px] w-[2px] -translate-x-1/2 rounded-full" style={{ background: 'var(--curio-accent)' }} />
    </div>
  )
}
components/curio/lib/slider.ts
'use client'

import { useRef, useState } from 'react'
import type { ThemeOption } from './types'

export type SliderProps = {
  options: ThemeOption[]
  value: number
  onChange: (index: number) => void
  /** accessible name */
  label?: string
}

export const clampTo = (n: number, count: number) => Math.max(0, Math.min(count - 1, n))

// arrow keys / home / end
export function sliderKeys(value: number, count: number, onChange: (i: number) => void) {
  return (e: React.KeyboardEvent) => {
    const next =
      e.key === 'ArrowRight' || e.key === 'ArrowUp'
        ? value + 1
        : e.key === 'ArrowLeft' || e.key === 'ArrowDown'
          ? value - 1
          : e.key === 'Home'
            ? 0
            : e.key === 'End'
              ? count - 1
              : null
    if (next === null) return
    e.preventDefault()
    onChange(clampTo(next, count))
  }
}

export function sliderAria({ options, value, label = 'Theme' }: SliderProps) {
  return {
    role: 'slider' as const,
    tabIndex: 0,
    'aria-label': label,
    'aria-valuemin': 0,
    'aria-valuemax': options.length - 1,
    'aria-valuenow': value,
    'aria-valuetext': options[value]?.label,
  }
}

// drag along x; reports the nearest step and how far past either end you pulled
export function useTrackDrag(width: number, count: number, onStep: (i: number) => void) {
  const ref = useRef<HTMLDivElement>(null)
  const [dragging, setDragging] = useState(false)
  const [overshoot, setOvershoot] = useState(0)

  const update = (clientX: number) => {
    const rect = ref.current!.getBoundingClientRect()
    const x = clientX - rect.left
    setOvershoot(x < 0 ? x : x > width ? x - width : 0)
    onStep(clampTo(Math.round((x / width) * (count - 1)), count))
  }
  const stop = () => {
    setDragging(false)
    setOvershoot(0)
  }
  return {
    ref,
    dragging,
    overshoot,
    handlers: {
      onPointerDown: (e: React.PointerEvent) => {
        ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
        setDragging(true)
        update(e.clientX)
      },
      onPointerMove: (e: React.PointerEvent) => dragging && update(e.clientX),
      onPointerUp: stop,
      onPointerCancel: stop,
    },
  }
}