no. 18 · sliders
Gradient slider
A thin bar painted with every option’s colour; the thumb shows a label.
Preview
Day
Install
npx shadcn@latest add https://curio-components.vercel.app/r/gradient-slider.jsonFirst curio component? Also add @import "./curio.css"; to your global CSS. See installation.
Usage
'use client'
import { useState } from 'react'
import { GradientSlider } from '@/components/curio/sliders/gradient-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 <GradientSlider options={themes} value={value} onChange={setValue} />
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| options | ThemeOption[] | - | Each step: label, swatch?, accent? |
| value | number | - | Index of the selected option. |
| onChange | (index: number) => void | - | Called with the new index. |
| label | string | "theme" | Accessible name. |
| width | number | 120 | Bar width in px. |
Source
This is exactly what the command copies into your project.
components/curio/sliders/gradient-slider.tsx
'use client'
import { useState } from 'react'
import { sliderAria, sliderKeys, useTrackDrag, type SliderProps } from '../lib/slider'
// a thin bar painted with every option's colour; the thumb shows a label
export function GradientSlider(props: SliderProps & { width?: number }) {
const { options, value, onChange, width = 120 } = props
const count = options.length
const { ref, dragging, handlers } = useTrackDrag(width, count, onChange)
const [hover, setHover] = useState(false)
const stops = options.map((o, i) => `${o.swatch ?? 'var(--curio-chip)'} ${(i / (count - 1)) * 100}%`).join(', ')
const left = `${(value / (count - 1)) * 100}%`
const ease = 'cubic-bezier(.23,1,.32,1)'
return (
<div
ref={ref}
{...sliderAria(props)}
onKeyDown={sliderKeys(value, count, onChange)}
{...handlers}
onPointerEnter={() => setHover(true)}
onPointerLeave={() => setHover(false)}
className="relative flex h-8 cursor-pointer touch-none select-none items-center outline-none"
style={{ width }}
>
<div className="h-[6px] w-full rounded-full border" style={{ background: `linear-gradient(90deg, ${stops})`, borderColor: 'var(--curio-border)' }} />
<span
className="absolute top-1/2 h-[14px] w-[14px] rounded-full border-2 shadow-[0_2px_6px_rgba(0,0,0,0.2)]"
style={{
left,
background: options[value]?.swatch ?? 'var(--curio-surface)',
borderColor: 'var(--curio-accent)',
transform: `translate(-50%, -50%) scale(${dragging ? 1.25 : 1})`,
transition: `left 280ms ${ease}, transform 200ms`,
}}
/>
<span
className="pointer-events-none absolute -top-5 -translate-x-1/2 px-1.5 py-0.5 text-[11px]"
style={{
left,
background: 'var(--curio-fg)',
color: 'var(--curio-bg)',
opacity: dragging || hover ? 1 : 0,
transition: `left 280ms ${ease}, opacity 150ms`,
}}
>
{options[value]?.label}
</span>
</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,
},
}
}