curio.
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.json

First 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

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.
nightFromnumberhalfIndex 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,
    },
  }
}