formsclient
slider
a raised knob riding in a groove. give it two numbers and it becomes a range with a knob at each end.
volume60%
installation
new to blurrow? this sets up your project in one go: the package, the styles and the font. already done it? skip straight to usage. the installation page has the details.
bash
npx @blurrow/ui init›rather own the code? copy the source instead
let the cli copy it, along with anything it needs:
bash
npx @blurrow/ui add slideror by hand: drop these files into components/ui and import from there. slider leans on FieldMessage.tsx, so those come along too.
components/ui/Slider.tsxtsx
1"use client";2
3import React, { useId, useRef, useState } from "react";4import { FieldLabel } from "./FieldMessage";5
6type SliderValue = number | [number, number];7
8interface SliderProps<V extends SliderValue> {9 /** One number for a slider, two for a range */10 value?: V;11 defaultValue?: V;12 onChange?: (value: V) => void;13 /** Fires once the knob is let go, for work too heavy to do while dragging */14 onCommit?: (value: V) => void;15 min?: number;16 max?: number;17 step?: number;18 label?: string;19 /** Show the value beside the label; pass a function to format it */20 showValue?: boolean | ((value: V) => React.ReactNode);21 /** Small labels under the track, at these values */22 marks?: number[];23 disabled?: boolean;24 /** Name of a hidden input (two for a range: name[0], name[1]) */25 name?: string;26 className?: string;27}28
29const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));30const decimals = (n: number) => (String(n).split(".")[1] ?? "").length;31
32/* A sunken track with the filled part in the inverse material, and a33 raised knob (or two, for a range) that you drag along it. The knob34 swells while held; arrows step, shift steps by ten, page keys by a35 tenth of the range, home and end jump to the ends. */36export function Slider<V extends SliderValue = number>({37 value,38 defaultValue,39 onChange,40 onCommit,41 min = 0,42 max = 100,43 step = 1,44 label,45 showValue = false,46 marks,47 disabled = false,48 name,49 className = "",50}: SliderProps<V>) {51 const uid = useId();52 const trackRef = useRef<HTMLDivElement>(null);53 const knobs = useRef<(HTMLSpanElement | null)[]>([]);54 const isRange = Array.isArray(value ?? defaultValue);55 const [internal, setInternal] = useState<V>(() => (defaultValue ?? value ?? (min as V)) as V);56 const current = (value !== undefined ? value : internal) as V;57 const values: number[] = Array.isArray(current) ? [...current] : [current as number];58 const [dragging, setDragging] = useState<number | null>(null);59 const digits = decimals(step);60
61 const snap = (n: number) => Number(clamp(Math.round((n - min) / step) * step + min, min, max).toFixed(digits));62 const pct = (n: number) => ((n - min) / (max - min)) * 100;63
64 const emit = (next: number[], commit = false) => {65 const v = (isRange ? [next[0], next[1]] : next[0]) as V;66 setInternal(v);67 if (next.some((n, i) => n !== values[i])) onChange?.(v);68 if (commit) onCommit?.(v);69 };70
71 /* Each knob is kept on its own side of the other */72 const setAt = (i: number, n: number, commit = false) => {73 const next = [...values];74 next[i] = snap(n);75 if (isRange) next[i] = i === 0 ? Math.min(next[0], values[1]) : Math.max(next[1], values[0]);76 emit(next, commit);77 };78
79 const fromPointer = (clientX: number) => {80 const r = trackRef.current!.getBoundingClientRect();81 return min + clamp((clientX - r.left) / r.width, 0, 1) * (max - min);82 };83
84 const onPointerDown = (e: React.PointerEvent) => {85 if (disabled || e.button !== 0) return;86 e.preventDefault();87 const n = fromPointer(e.clientX);88 // Grab whichever knob is nearer; on a tie, the one that can move that way89 let i = 0;90 if (isRange) {91 const d0 = Math.abs(n - values[0]);92 const d1 = Math.abs(n - values[1]);93 i = d1 < d0 || (d0 === d1 && n > values[1]) ? 1 : 0;94 }95 (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);96 setDragging(i);97 knobs.current[i]?.focus();98 setAt(i, n);99 };100
101 const onKeyDown = (i: number) => (e: React.KeyboardEvent) => {102 const big = Math.max(step, (max - min) / 10);103 const moves: Record<string, number> = {104 ArrowRight: step * (e.shiftKey ? 10 : 1),105 ArrowUp: step * (e.shiftKey ? 10 : 1),106 ArrowLeft: -step * (e.shiftKey ? 10 : 1),107 ArrowDown: -step * (e.shiftKey ? 10 : 1),108 PageUp: big,109 PageDown: -big,110 };111 if (e.key in moves) {112 e.preventDefault();113 setAt(i, values[i] + moves[e.key], true);114 } else if (e.key === "Home") {115 e.preventDefault();116 setAt(i, min, true);117 } else if (e.key === "End") {118 e.preventDefault();119 setAt(i, max, true);120 }121 };122
123 const shown =124 typeof showValue === "function"125 ? showValue(current)126 : showValue127 ? values.join(" – ")128 : null;129
130 const lo = isRange ? pct(values[0]) : 0;131 const hi = pct(values[isRange ? 1 : 0]);132
133 return (134 <div className={["flex w-full flex-col gap-2.5", disabled ? "opacity-40" : "", className].join(" ")}>135 {(label || shown !== null) && (136 <div className="flex items-center justify-between gap-4">137 {label && <FieldLabel id={`${uid}-label`}>{label}</FieldLabel>}138 {shown !== null && <span className="text-[12.5px] tabular-nums text-ink-muted">{shown}</span>}139 </div>140 )}141
142 <div143 className={["relative flex h-6 touch-none items-center", disabled ? "pointer-events-none" : "cursor-pointer"].join(" ")}144 onPointerDown={onPointerDown}145 onPointerMove={(e) => dragging !== null && setAt(dragging, fromPointer(e.clientX))}146 onPointerUp={() => {147 if (dragging !== null) onCommit?.(current);148 setDragging(null);149 }}150 onPointerCancel={() => setDragging(null)}151 >152 {/* The track: a groove cut into the surface */}153 <div154 ref={trackRef}155 className="relative h-2 w-full rounded-full"156 style={{ background: "var(--track)", boxShadow: "var(--track-shadow)" }}157 >158 <div159 className="absolute inset-y-px rounded-full"160 style={{161 left: `calc(${lo}% + ${isRange ? 0 : 1}px)`,162 width: `calc(${hi - lo}% - ${isRange ? 0 : 1}px)`,163 background: "var(--face-inverse)",164 boxShadow: "var(--fill-shadow)",165 transition: dragging === null ? "left 200ms var(--ease-out-soft), width 200ms var(--ease-out-soft)" : "none",166 }}167 />168 </div>169
170 {values.map((v, i) => (171 <span172 key={i}173 ref={(el) => {174 knobs.current[i] = el;175 }}176 role="slider"177 tabIndex={disabled ? -1 : 0}178 aria-labelledby={label ? `${uid}-label` : undefined}179 aria-label={isRange ? (i === 0 ? "minimum" : "maximum") : label ? undefined : "value"}180 aria-valuemin={isRange && i === 1 ? values[0] : min}181 aria-valuemax={isRange && i === 0 ? values[1] : max}182 aria-valuenow={v}183 aria-disabled={disabled || undefined}184 onKeyDown={onKeyDown(i)}185 className="absolute top-1/2 h-5 w-5 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-page"186 style={{187 left: `${pct(v)}%`,188 // the last-dragged knob of a range sits on top, so two at one spot can be pulled apart189 zIndex: dragging === i ? 2 : 1,190 background: "var(--face)",191 boxShadow: "var(--knob-shadow)",192 transform: `translate(-50%, -50%) scale(${dragging === i ? 1.15 : 1})`,193 transition:194 dragging === null195 ? "left 200ms var(--ease-out-soft), transform 220ms var(--ease-spring)"196 : "transform 220ms var(--ease-spring)",197 }}198 >199 {/* A small dimple on the knob's crown */}200 <span201 aria-hidden202 className="absolute left-1/2 top-1/2 h-1.5 w-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full"203 style={{ background: "var(--track)", boxShadow: "var(--track-shadow)" }}204 />205 </span>206 ))}207 </div>208
209 {marks && marks.length > 0 && (210 <div aria-hidden className="relative h-4">211 {marks.map((m) => (212 <span213 key={m}214 className="absolute -translate-x-1/2 text-[11px] tabular-nums text-ink-faint"215 style={{ left: `${pct(m)}%` }}216 >217 {m}218 </span>219 ))}220 </div>221 )}222
223 {name &&224 values.map((v, i) => (225 <input key={i} type="hidden" name={isRange ? `${name}[${i}]` : name} value={v} />226 ))}227 </div>228 );229}components/ui/FieldMessage.tsxtsx
1"use client";2
3import React from "react";4
5/* The label above a field and the line under it, shared by every form6 control so they read as one family. */7
8export function FieldLabel({9 id,10 htmlFor,11 children,12}: {13 id?: string;14 htmlFor?: string;15 children: React.ReactNode;16}) {17 const className =18 "text-[12.5px] font-medium text-ink-label tracking-[-0.005em] transition-colors duration-200 group-focus-within/field:text-ink-strong";19 return htmlFor ? (20 <label id={id} htmlFor={htmlFor} className={className}>21 {children}22 </label>23 ) : (24 <span id={id} className={className}>25 {children}26 </span>27 );28}29
30/* An error wins over a hint; either is announced through `id` */31export function FieldMessage({ id, error, hint }: { id?: string; error?: string; hint?: string }) {32 if (error) {33 return (34 <p id={id} role="alert" className="text-[11.5px] text-danger font-medium flex items-center gap-1 mt-0.5">35 <svg aria-hidden width="11" height="11" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">36 <circle cx="12" cy="12" r="10" />37 <path strokeLinecap="round" d="M12 8v4m0 4h.01" />38 </svg>39 {error}40 </p>41 );42 }43 if (hint) {44 return (45 <p id={id} className="text-[11.5px] text-ink-muted mt-0.5">46 {hint}47 </p>48 );49 }50 return null;51}52
53/* A small raised key that sits inside a field or a panel: steppers,54 month arrows, clear buttons. It sinks flush while held. */55export function FieldKey({56 label,57 disabled,58 onClick,59 onPress,60 onRelease,61 focusable = true,62 size = 28,63 children,64}: {65 label: string;66 disabled?: boolean;67 onClick?: () => void;68 /** Pointer went down (for hold-to-repeat) */69 onPress?: () => void;70 onRelease?: () => void;71 focusable?: boolean;72 size?: number;73 children: React.ReactNode;74}) {75 const [pressed, setPressed] = React.useState(false);76 const release = () => {77 setPressed(false);78 onRelease?.();79 };80 return (81 <button82 type="button"83 aria-label={label}84 tabIndex={focusable ? undefined : -1}85 disabled={disabled}86 onClick={onClick}87 onPointerDown={(e) => {88 if (e.button !== 0) return;89 if (!focusable) e.preventDefault(); // keep focus in the field90 setPressed(true);91 onPress?.();92 }}93 onPointerUp={release}94 onPointerLeave={() => pressed && release()}95 onPointerCancel={release}96 className="grid shrink-0 cursor-pointer place-items-center rounded-[9px] text-ink-muted transition-[box-shadow,color,opacity] duration-150 hover:text-ink focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ink disabled:opacity-35 disabled:pointer-events-none"97 style={{98 width: size,99 height: size,100 background: "var(--face)",101 boxShadow: pressed ? "var(--face-pressed)" : "var(--key-shadow)",102 }}103 >104 <svg105 aria-hidden106 width="12"107 height="12"108 fill="none"109 stroke="currentColor"110 strokeWidth="2.25"111 strokeLinecap="round"112 strokeLinejoin="round"113 viewBox="0 0 24 24"114 style={{115 transform: pressed ? "translateY(0.5px) scale(0.92)" : "none",116 transition: "transform 150ms var(--ease-spring)",117 }}118 >119 {children}120 </svg>121 </button>122 );123}usage
tsx
import { Slider } from "@blurrow/ui";tsx
<Slider label="volume" value={volume} onChange={setVolume} />examples
range
pass a pair of numbers and you get two knobs that can't cross.
price$40 – $160
marks and disabled
quality
0255075100
locked
props
straight from the source, so they're always up to date.
propsts
type SliderValue = number | [number, number];
interface SliderProps<V extends SliderValue> { /** One number for a slider, two for a range */ value?: V; defaultValue?: V; onChange?: (value: V) => void; /** Fires once the knob is let go, for work too heavy to do while dragging */ onCommit?: (value: V) => void; min?: number; max?: number; step?: number; label?: string; /** Show the value beside the label; pass a function to format it */ showValue?: boolean | ((value: V) => React.ReactNode); /** Small labels under the track, at these values */ marks?: number[]; disabled?: boolean; /** Name of a hidden input (two for a range: name[0], name[1]) */ name?: string; className?: string;}