no. 14 · sliders
Sun arc slider
Drag the sun across the sky; it becomes a moon after dark.
Preview
Day
Install
npx shadcn@latest add https://curio-components.vercel.app/r/sun-arc-slider.jsonFirst curio component? Also add @import "./curio.css"; to your global CSS. See installation.
Usage
'use client'
import { useState } from 'react'
import { SunArcSlider } from '@/components/curio/sliders/sun-arc-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 <SunArcSlider 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. |
| nightFrom | number | half | Index where the sun becomes a moon. |
Source
This is exactly what the command copies into your project.
components/curio/sliders/sun-arc-slider.tsx
'use client'
import { useId, useRef, useState } from 'react'
import { clampTo, sliderAria, sliderKeys, type SliderProps } from '../lib/slider'
// drag the sun across the sky; it turns into a moon from `nightFrom` onwards
export function SunArcSlider(props: SliderProps & { nightFrom?: number }) {
const { options, value, onChange } = props
const count = options.length
const nightFrom = props.nightFrom ?? Math.ceil(count / 2)
const ref = useRef<SVGSVGElement>(null)
const maskId = useId().replace(/:/g, '')
const [dragging, setDragging] = useState(false)
const W = 132
const H = 66
const cx = W / 2
const cy = H - 8
const r = 54
const angleOf = (i: number) => Math.PI - (i / (count - 1)) * Math.PI
const a = angleOf(value)
const sx = cx + r * Math.cos(a)
const sy = cy - r * Math.sin(a)
const moon = value >= nightFrom
const update = (e: React.PointerEvent) => {
const rect = ref.current!.getBoundingClientRect()
const x = e.clientX - rect.left - cx
const y = cy - (e.clientY - rect.top)
const ang = Math.atan2(Math.max(y, 0), x)
onChange(clampTo(Math.round(((Math.PI - ang) / Math.PI) * (count - 1)), count))
}
return (
<div className="flex flex-col items-center gap-1">
<svg
ref={ref}
width={W}
height={H}
{...sliderAria(props)}
onKeyDown={sliderKeys(value, count, onChange)}
onPointerDown={(e) => {
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
setDragging(true)
update(e)
}}
onPointerMove={(e) => dragging && update(e)}
onPointerUp={() => setDragging(false)}
className="cursor-grab touch-none select-none overflow-visible outline-none active:cursor-grabbing"
>
<path d={`M ${cx - r} ${cy} A ${r} ${r} 0 0 1 ${cx + r} ${cy}`} fill="none" stroke="var(--curio-border)" strokeDasharray="2 4" />
<line x1={4} x2={W - 4} y1={cy} y2={cy} stroke="var(--curio-border)" />
{options.map((_, i) => (
<circle key={i} cx={cx + r * Math.cos(angleOf(i))} cy={cy - r * Math.sin(angleOf(i))} r={1.6} fill="var(--curio-muted)" />
))}
<g style={{ transform: `translate(${sx}px, ${sy}px)`, transition: 'transform 380ms cubic-bezier(.23,1,.32,1)' }}>
<mask id={maskId}>
<rect x={-12} y={-12} width={24} height={24} fill="white" />
<circle r={7} fill="black" style={{ transform: moon ? 'translate(4px,-3px)' : 'translate(14px,-12px)', transition: 'transform 380ms ease' }} />
</mask>
{!moon && (
<g stroke="var(--curio-accent)" strokeWidth={1.6} strokeLinecap="round">
{Array.from({ length: 8 }, (_, i) => {
const t = (i / 8) * Math.PI * 2
return <line key={i} x1={Math.cos(t) * 10} y1={Math.sin(t) * 10} x2={Math.cos(t) * 13} y2={Math.sin(t) * 13} />
})}
</g>
)}
<circle r={moon ? 8 : 6.5} fill="var(--curio-accent)" mask={`url(#${maskId})`} />
</g>
</svg>
<span className="text-xs" style={{ color: 'var(--curio-muted)' }}>
{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,
},
}
}