# Effort Slider

> A model-effort picker: a stepped slider whose thumb is pulled toward the nearest level and springs onto it, with a cross-fading level name, a recommended mark and a pixel field that lights the track at the top level.

A shadcn/ui block from the [@sortui](https://sortui.dev/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/sortui/effort-slider-01).

- Page, with a live preview: https://shadcn.exchange/r/sortui/effort-slider-01
- Screenshot: https://shadcn.exchange/shot/sortui/effort-slider-01/light.png
- Registry item: https://sortui.dev/r/effort-slider-01.json
- Categories: ai
- Listed: 2026-10-23, source last changed 2026-10-23

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/sortui/effort-slider-01.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@base-ui/react`, `cn`

## Files

### `registry/blocks/effort-slider-01/components/effort-slider.tsx`

```tsx
"use client"

import * as React from "react"
import { useDirection } from "@base-ui/react/direction-provider"
import { Slider as SliderPrimitive } from "@base-ui/react/slider"

import { cn } from "cn"

import { drawPixelField } from "@/registry/blocks/effort-slider-01/lib/pixel-field"

const defaultLevels = ["Low", "Medium", "High", "Extra", "Max", "Ultra"]

const clamp = (value: number, last: number) =>
  Math.min(last, Math.max(0, value))

const revealAt = (elapsed: number) => {
  const t = Math.min(1, elapsed / 1000)
  return t * t * (3 - 2 * t)
}

const prefersReducedMotion = () =>
  window.matchMedia("(prefers-reduced-motion: reduce)").matches

const stepAt = (index: number, last: number) =>
  `calc(var(--thumb) / 2 + (100% - var(--thumb)) * ${index / last})`

const keyTargets: Record<
  string,
  (level: number, last: number, rtl: boolean) => number
> = {
  ArrowLeft: (level, _, rtl) => (rtl ? level + 1 : level - 1),
  ArrowDown: (level) => level - 1,
  PageDown: (level) => level - 1,
  ArrowRight: (level, _, rtl) => (rtl ? level - 1 : level + 1),
  ArrowUp: (level) => level + 1,
  PageUp: (level) => level + 1,
  Home: () => 0,
  End: (_, last) => last,
}

const DETENT_RADIUS = 0.45
const DETENT_PULL = 0.9

function detent(value: number) {
  const level = Math.round(value)
  const offset = value - level
  const closeness = 1 - Math.abs(offset) / DETENT_RADIUS
  if (closeness <= 0) return value
  return level + offset * (1 - DETENT_PULL * closeness * closeness)
}

const SPRING_FREQUENCY = 30
const SPRING_DAMPING_RATIO = 0.65
const MAX_SPEED = 8
const MAX_OFFSET = 0.49
const SPEED_SMOOTHING = 0.5

function springOffset(offset: number, speed: number, t: number) {
  const decay = SPRING_DAMPING_RATIO * SPRING_FREQUENCY
  const frequency = SPRING_FREQUENCY * Math.sqrt(1 - SPRING_DAMPING_RATIO ** 2)
  return (
    Math.exp(-decay * t) *
    (offset * Math.cos(frequency * t) +
      ((speed + decay * offset) / frequency) * Math.sin(frequency * t))
  )
}

function useSnappingValue(initial: number, last: number) {
  const [value, setValue] = React.useState(initial)
  const current = React.useRef(initial)
  const motion = React.useRef({ speed: 0, time: 0 })
  const frame = React.useRef(0)

  React.useEffect(() => () => cancelAnimationFrame(frame.current), [])

  const set = React.useCallback((next: number) => {
    current.current = next
    setValue(next)
  }, [])

  const drag = (raw: number) => {
    cancelAnimationFrame(frame.current)
    const next = detent(raw)
    const now = performance.now()
    const seconds = (now - motion.current.time) / 1000
    const instant =
      seconds > 0 && seconds < 0.1 ? (next - current.current) / seconds : 0
    motion.current = {
      speed:
        motion.current.speed +
        (instant - motion.current.speed) * SPEED_SMOOTHING,
      time: now,
    }
    set(next)
  }

  const jump = React.useCallback(
    (target: number) => {
      cancelAnimationFrame(frame.current)
      motion.current = { speed: 0, time: 0 }
      set(target)
    },
    [set]
  )

  const release = () => {
    const target = Math.round(current.current)
    const offset = current.current - target
    const idle = performance.now() - motion.current.time > 100
    const speed = idle
      ? 0
      : Math.max(-MAX_SPEED, Math.min(MAX_SPEED, motion.current.speed))
    motion.current = { speed: 0, time: 0 }
    if (prefersReducedMotion() || (Math.abs(offset) < 0.001 && !speed)) {
      set(target)
      return target
    }
    const start = performance.now()
    const step = (time: number) => {
      const t = (time - start) / 1000
      const now = springOffset(offset, speed, t)
      const settled =
        Math.abs(now) < 0.001 && Math.exp(-SPRING_FREQUENCY * t) < 0.01
      if (settled) {
        set(target)
        return
      }
      set(
        clamp(target + Math.max(-MAX_OFFSET, Math.min(MAX_OFFSET, now)), last)
      )
      frame.current = requestAnimationFrame(step)
    }
    frame.current = requestAnimationFrame(step)
    return target
  }

  return { value, drag, jump, release }
}

const LevelName = React.memo(function LevelName({
  levels,
  level,
}: {
  levels: string[]
  level: number
}) {
  const [shown, setShown] = React.useState({
    level,
    leaving: null as number | null,
    rising: true,
  })
  if (shown.level !== level) {
    setShown({ level, leaving: shown.level, rising: level > shown.level })
  }

  return (
    <span
      className={cn(
        "relative inline-grid transition-colors duration-200",
        level === levels.length - 1 && "text-primary"
      )}
    >
      {levels.map((name) => (
        <span key={name} aria-hidden className="invisible [grid-area:1/1]">
          {name}
        </span>
      ))}
      {shown.leaving !== null && (
        <span
          key={`leaving-${shown.leaving}-${shown.level}`}
          aria-hidden
          className={cn(
            "pointer-events-none absolute inset-0 animate-out duration-150 fill-mode-forwards fade-out motion-reduce:hidden",
            shown.rising ? "slide-out-to-top-1" : "slide-out-to-bottom-1"
          )}
          onAnimationEnd={() =>
            setShown((state) => ({ ...state, leaving: null }))
          }
        >
          {levels[shown.leaving]}
        </span>
      )}
      <span
        key={shown.level}
        className={cn(
          "absolute inset-0",
          shown.leaving !== null &&
            cn(
              "animate-in duration-150 fade-in motion-reduce:animate-none",
              shown.rising ? "slide-in-from-bottom-1" : "slide-in-from-top-1"
            )
        )}
      >
        {levels[level]}
      </span>
    </span>
  )
})

const StepMarks = React.memo(function StepMarks({
  levels,
  recommended,
  hidden,
}: {
  levels: string[]
  recommended?: number
  hidden: boolean
}) {
  const rtl = useDirection() === "rtl"

  return levels.map((name, index) => (
    <span
      key={name}
      aria-hidden
      className={cn(
        "pointer-events-none absolute top-1/2 -translate-y-1/2 transition-opacity duration-200",
        rtl ? "translate-x-1/2" : "-translate-x-1/2",
        hidden && "opacity-0"
      )}
      style={{ insetInlineStart: stepAt(index, levels.length - 1) }}
    >
      {index === recommended ? (
        <span className="block h-3 w-[3px] rounded-xs bg-foreground/30" />
      ) : (
        <span className="block size-1 rounded-full bg-foreground/30" />
      )}
    </span>
  ))
})

const PixelField = React.memo(function PixelField({
  active,
}: {
  active: boolean
}) {
  const ref = React.useRef<HTMLCanvasElement>(null)
  const rtl = useDirection() === "rtl"

  React.useEffect(() => {
    const canvas = ref.current
    if (!canvas || !active) return
    canvas.getContext("2d")?.clearRect(0, 0, canvas.width, canvas.height)
    const reduced = prefersReducedMotion()
    const style = getComputedStyle(canvas)
    const draw = (elapsed: number, reveal: number) => {
      drawPixelField(canvas, { elapsed, reveal, color: style.color })
    }
    const resize = () => {
      const ratio = Math.min(window.devicePixelRatio || 1, 2)
      const width = Math.round(canvas.clientWidth * ratio)
      const height = Math.round(canvas.clientHeight * ratio)
      if (canvas.width !== width || canvas.height !== height) {
        canvas.width = width
        canvas.height = height
      }
      if (reduced) draw(0, 1)
    }
    resize()
    const observer = new ResizeObserver(resize)
    observer.observe(canvas)

    const started = performance.now()
    let frame = 0
    if (!reduced) {
      let drawnAt = 0
      const loop = (time: number) => {
        if (time - drawnAt >= 33) {
          drawnAt = time
          const elapsed = time - started
          draw(elapsed, revealAt(elapsed))
        }
        frame = requestAnimationFrame(loop)
      }
      frame = requestAnimationFrame(loop)
    }
    return () => {
      cancelAnimationFrame(frame)
      observer.disconnect()
    }
  }, [active])

  return (
    <canvas
      ref={ref}
      aria-hidden
      className={cn(
        "pointer-events-none absolute inset-0 size-full [mask-image:linear-gradient(to_right,transparent,black_70%)] text-primary transition-opacity duration-200",
        rtl && "-scale-x-100",
        active ? "opacity-100" : "opacity-0"
      )}
    />
  )
})

type EffortSliderProps = Omit<React.ComponentProps<"div">, "onChange"> & {
  /** Level names, lowest first. The last one lights the track. */
  levels?: string[]
  /** Index of the selected level, when controlled. */
  value?: number
  /** Index of the level the picker starts on. */
  defaultValue?: number
  /** Index of the level marked as recommended on the track. */
  recommended?: number
  /** Called with the level's index when the thumb settles on another level. */
  onValueChange?: (level: number) => void
  label?: string
  minLabel?: string
  maxLabel?: string
}

function EffortSlider({
  className,
  levels = defaultLevels,
  value: controlled,
  defaultValue = 2,
  recommended = 1,
  onValueChange,
  label = "Effort",
  minLabel = "Faster",
  maxLabel = "Smarter",
  ...props
}: EffortSliderProps) {
  const last = levels.length - 1
  const initial = clamp(Math.round(controlled ?? defaultValue), last)
  const { value, drag, jump, release } = useSnappingValue(initial, last)
  const level = clamp(Math.round(value), last)
  const top = level === last
  const rtl = useDirection() === "rtl"

  const settled = React.useRef(initial)
  const [settleCount, setSettleCount] = React.useState(0)
  const settle = (target: number) => {
    if (settled.current === target) return
    settled.current = target
    onValueChange?.(target)
    setSettleCount((count) => count + 1)
  }

  React.useEffect(() => {
    if (controlled === undefined) return
    const target = clamp(Math.round(controlled), last)
    if (settled.current === target) return
    settled.current = target
    jump(target)
  }, [controlled, last, jump, settleCount])

  return (
    <div
      data-slot="effort-slider"
      className={cn(
        "flex w-72 flex-col gap-4 rounded-4xl bg-card p-5 text-sm shadow-2xs ring-1 ring-border [--thumb:--spacing(5)]",
        className
      )}
      {...props}
    >
      <div className="flex items-center gap-1.5 text-base font-medium">
        <span className="text-muted-foreground">{label}</span>
        <LevelName levels={levels} level={level} />
      </div>
      <div className="flex flex-col gap-2">
        <div aria-hidden className="flex justify-between text-muted-foreground">
          <span>{minLabel}</span>
          <span>{maxLabel}</span>
        </div>
        <SliderPrimitive.Root
          value={[value]}
          onValueChange={(next, { reason }) => {
            const raw = Array.isArray(next) ? next[0] : next
            if (reason !== "input-change") return drag(raw)
            const nearest = clamp(Math.round(raw), last)
            const target =
              nearest !== level
                ? nearest
                : clamp(level + Math.sign(raw - value), last)
            jump(target)
            settle(target)
          }}
          onValueCommitted={() => settle(release())}
          min={0}
          max={last}
          step={0.001}
          thumbAlignment="edge"
        >
          <SliderPrimitive.Control className="relative flex h-6 w-full cursor-ew-resize touch-none items-center select-none">
            <SliderPrimitive.Track className="relative size-full overflow-hidden rounded-sm bg-fill-2">
              <SliderPrimitive.Indicator
                className={cn(
                  "box-content h-full rounded-sm bg-fill-4 pr-[calc(var(--thumb)/2)] transition-opacity duration-200",
                  top && "opacity-0"
                )}
              />
              <PixelField active={top} />
              <StepMarks
                levels={levels}
                recommended={recommended}
                hidden={top}
              />
            </SliderPrimitive.Track>
            <SliderPrimitive.Thumb
              aria-label={label}
              getAriaValueText={(_, thumbValue) => {
                const index = Math.round(thumbValue)
                return index === recommended
                  ? `${levels[index]}, recommended`
                  : levels[index]
              }}
              onKeyDown={(event) => {
                const target = keyTargets[event.key]
                if (!target) return
                event.preventDefault()
                event.stopPropagation()
                const next = clamp(target(level, last, rtl), last)
                jump(next)
                settle(next)
              }}
              className="z-10 h-6 w-(--thumb) rounded-sm bg-white shadow-md ring-1 ring-black/10 transition-[scale,box-shadow] duration-200 select-none not-dark:bg-clip-padding hover:ring-4 hover:ring-ring/30 active:scale-[0.96] has-focus-visible:ring-4 has-focus-visible:ring-ring/30"
            />
          </SliderPrimitive.Control>
        </SliderPrimitive.Root>
      </div>
    </div>
  )
}

export { EffortSlider, type EffortSliderProps }
```

### `registry/blocks/effort-slider-01/lib/pixel-field.ts`

```ts
export type PixelFieldFrame = {
  /** Milliseconds since the field appeared. */
  elapsed: number
  /** 0 → 1: how far the field has swept in from the right edge. */
  reveal: number
  color: string
}

const ROWS = 7
const GAP = 0.25
const ROUNDING = 0.3

const REST = 0.7
const FLARE = 0.8
const BAND = 0.5
const DIMS = [
  { opacity: 0.5, weight: 3 },
  { opacity: 0.4, weight: 2 },
  { opacity: 0.3, weight: 3 },
  { opacity: 0.2, weight: 3 },
]
const DIM_WEIGHT = DIMS.reduce((sum, dim) => sum + dim.weight, 0)

const CYCLE_MIN = 600
const CYCLE_MAX = 2000
const DIM_CHANCE = 0.85
const FLARE_CHANCE = 0.1
const EVENT_LENGTH = 0.25

const BAND_SLOT = 5
const BAND_CHANCE = 0.1
const BAND_STEP_MS = 4000

const SWEEP_EDGE = 0.15

function noise(a: number, b: number, c = 0) {
  let h = Math.imul(a | 0, 0x27d4eb2d) ^ Math.imul(b | 0, 0x165667b1)
  h = Math.imul(h ^ Math.imul(c | 0, 0x9e3779b1), 0x85ebca6b)
  h ^= h >>> 13
  h = Math.imul(h, 0xc2b2ae35)
  h ^= h >>> 16
  return (h >>> 0) / 4294967296
}

const easeInOut = (t: number) => t * t * (3 - 2 * t)

function cellOpacity(column: number, row: number, elapsed: number) {
  const length = CYCLE_MIN + noise(column, row, 1) * (CYCLE_MAX - CYCLE_MIN)
  const time = elapsed + noise(column, row, 2) * length
  const cycle = Math.floor(time / length)
  const position = (time % length) / length

  const roll = noise(column, row, 100 + cycle)
  const start = noise(column, row, 200 + cycle) * (1 - EVENT_LENGTH)
  const inEvent = position >= start && position < start + EVENT_LENGTH
  const depth = inEvent
    ? 1 - Math.abs(((position - start) / EVENT_LENGTH) * 2 - 1)
    : 0

  if (depth > 0 && roll < FLARE_CHANCE) return depth > 0.2 ? FLARE : REST
  if (depth > 0 && roll < FLARE_CHANCE + DIM_CHANCE) {
    let pick = noise(column, row, 300 + cycle) * DIM_WEIGHT
    const target = DIMS.find((dim) => (pick -= dim.weight) < 0) ?? DIMS[0]
    const steps = Math.round((REST - target.opacity) * 10)
    const reached = Math.ceil(Math.min(1, depth * 2) * steps)
    return Math.min(BAND, REST - reached / 10)
  }
  return REST
}

function inBand(column: number, row: number, elapsed: number) {
  const steps = elapsed / BAND_STEP_MS
  const offset = Math.floor(steps) + easeInOut(steps % 1)
  const slot = Math.floor(column / BAND_SLOT + offset)
  return noise(slot, row, 400) < BAND_CHANCE
}

function fillCell(
  context: CanvasRenderingContext2D,
  x: number,
  y: number,
  size: number
) {
  if (!context.roundRect) return context.fillRect(x, y, size, size)
  context.beginPath()
  context.roundRect(x, y, size, size, size * ROUNDING)
  context.fill()
}

export function drawPixelField(
  canvas: HTMLCanvasElement,
  { elapsed, reveal, color }: PixelFieldFrame
) {
  const context = canvas.getContext("2d")
  if (!context || !canvas.width || !canvas.height) return
  const ratio = canvas.width / canvas.clientWidth || 1
  const width = canvas.clientWidth
  const height = canvas.clientHeight
  context.setTransform(ratio, 0, 0, ratio, 0, 0)
  context.clearRect(0, 0, width, height)
  context.fillStyle = color

  const pitch = height / ROWS
  const gap = pitch * GAP
  const size = pitch - gap
  const columns = Math.ceil(width / pitch)
  const edge = 1 - reveal * (1 + SWEEP_EDGE)

  for (let row = 0; row < ROWS; row += 1) {
    for (let column = 0; column < columns; column += 1) {
      const along = (column + 0.5) / columns
      const swept = Math.min(1, Math.max(0, (along - edge) / SWEEP_EDGE))
      if (swept === 0) continue

      let opacity = cellOpacity(column, row, elapsed)
      if (opacity === REST && inBand(column, row, elapsed)) opacity = BAND
      const alpha = Math.round(opacity * swept * 10) / 10
      if (alpha === 0) continue

      context.globalAlpha = alpha
      fillCell(context, column * pitch + gap / 2, row * pitch + gap / 2, size)
    }
  }
  context.globalAlpha = 1
}
```
