Marks as dots inside the track: ticks, a selector's options and a slider's reading on the track's midline and the ring's centre line, coloured by the fill or the handle over them; the selector ring fills to its option

This commit is contained in:
Melvin Strobl
2026-10-14 16:42:56 +02:00
parent 42c752c94e
commit bae4b15505
12 changed files with 387 additions and 112 deletions
+112 -41
View File
@@ -1,5 +1,5 @@
import { motion, useTransform } from "motion/react"
import { useEffect, useRef } from "react"
import { type MotionValue, motion, useTransform } from "motion/react"
import { useEffect, useId, useRef } from "react"
import type { WidgetDef } from "@/client"
import { cn } from "@/lib/utils"
@@ -21,6 +21,7 @@ import {
showTitle,
text,
} from "./ui/core/config"
import { tickIntervals } from "./ui/core/controls"
import {
CENTRE_LABEL,
CENTRE_SWATCH,
@@ -34,7 +35,6 @@ import {
RING_INSET,
RING_MARK,
RING_READING,
RING_READING_STROKE,
RING_STROKE,
ringArc,
ringPoint,
@@ -78,10 +78,13 @@ type Dial = {
/** Written out; empty for a colour, whose handle is the reading. */
reading: (value: unknown) => string
unit?: string
/** A value arc from the start to the reading: a slider and a gauge. */
/** A value arc from the start to the reading: a slider, a selector and a
* gauge. */
fill?: boolean
handle?: boolean
marks?: number[]
/** Dots along the sweep, as fractions of it: a selector's options, a
* slider's ticks. */
stops?: number[]
hue?: boolean
}
@@ -109,8 +112,9 @@ function dialOf(widget: WidgetDef, live: unknown): Dial {
const option = options[index as number]
return option ? (option.label ?? text(option.value)) : "—"
},
fill: true,
handle: true,
marks: options.map((_, index) => at(index) ?? 0.5),
stops: options.map((_, index) => at(index) ?? 0.5),
}
}
@@ -159,6 +163,8 @@ function dialOf(widget: WidgetDef, live: unknown): Dial {
const digits = (String(step).split(".")[1] ?? "").length
const fit = (value: number) =>
Number(Math.min(max, Math.max(min, value)).toFixed(digits))
// The tile's scale, as dots: the same intervals its labels are cut into.
const intervals = tickIntervals((max - min) / step)
return {
value: typeof live === "number" ? live : min,
at: (value) => fractionOf(value as number, min, max),
@@ -170,6 +176,10 @@ function dialOf(widget: WidgetDef, live: unknown): Dial {
unit,
fill: true,
handle: true,
stops:
cfg.ticks !== false && max > min
? Array.from({ length: intervals + 1 }, (_, index) => index / intervals)
: [],
}
}
@@ -333,14 +343,16 @@ export function Ring({
// Written to the slider's own decimals and in its unit: the two are read
// against each other.
const other = beside.message ? dial.reading(read?.value ?? null) : null
// The same reading, placed: a bar across the band where it sits on the
// slider's scale, held to the sweep at either end.
// The same reading, placed: a dot where it sits on the slider's scale,
// held to the sweep at either end.
const actual = typeof read?.value === "number" ? dial.at(read.value) : null
const across = useTransform(useEased(actual ?? 0), (fraction) => {
const from = ringPoint(size, fraction, -RING_READING / 2)
const to = ringPoint(size, fraction, RING_READING / 2)
return `M${from.x} ${from.y}L${to.x} ${to.y}`
})
const readAt = useEased(actual ?? 0)
const readX = useTransform(readAt, (fraction) => ringPoint(size, fraction).x)
const readY = useTransform(readAt, (fraction) => ringPoint(size, fraction).y)
const readDot = actual === null ? null : { x: readX, y: readY }
// What the dots are drawn under: the value arc and the handle, which
// repaint whatever part of a dot they cover (`RingDots`).
const id = useId().replace(/[^\w-]/g, "")
const whole = ringArc(size, 0, 1)
const name = widget.title || text(cfg.target, widget.id)
@@ -397,38 +409,23 @@ export function Ring({
className="dui-ring-track"
/>
)}
<RingDots size={size} stops={dial.stops} reading={readDot} />
{/* Revealed rather than re-pathed, as the gauge tile's arc is: `d`
does not ease, and `pathLength` makes the reveal the fraction. */}
{dial.fill ? (
<motion.path
d={whole}
fill="none"
strokeWidth={RING_STROKE}
strokeLinecap="round"
className="dui-ring-fill"
style={{ pathLength: along, opacity: shown }}
/>
) : null}
{dial.marks?.map((mark, index) => {
const point = ringPoint(size, mark)
return (
<circle
// Two options may share a label; position is the identity.
key={`mark-${index}`}
cx={point.x}
cy={point.y}
r={RING_MARK / 2}
className="dui-ring-mark"
<>
<motion.path
d={whole}
fill="none"
strokeWidth={RING_STROKE}
strokeLinecap="round"
className="dui-ring-fill"
style={{ pathLength: along, opacity: shown }}
/>
)
})}
{/* Under the handle, so where the two meet the value being set wins. */}
{actual !== null ? (
<motion.path
d={across}
strokeWidth={RING_READING_STROKE}
className="dui-ring-reading"
/>
<g className="dui-ring-reached" mask={`url(#${id}-fill)`}>
<RingDots size={size} stops={dial.stops} reading={readDot} />
</g>
</>
) : null}
{dial.handle && at !== null ? (
<motion.circle
@@ -439,7 +436,36 @@ export function Ring({
style={dial.hue ? { fill: cssOf(current as Triple) } : undefined}
/>
) : null}
{readDot && dial.handle && at !== null ? (
<g className="dui-ring-on-handle" clipPath={`url(#${id}-handle)`}>
<RingDots size={size} reading={readDot} />
</g>
) : null}
</g>
<defs>
{/* The value arc again, as a stencil: a dot it has reached is
drawn in the colour that reads on it, to the pixel it covers. */}
<mask
id={`${id}-fill`}
maskUnits="userSpaceOnUse"
x={0}
y={0}
width={size}
height={size}
>
<motion.path
d={whole}
fill="none"
stroke="#fff"
strokeWidth={RING_STROKE}
strokeLinecap="round"
style={{ pathLength: along, opacity: shown }}
/>
</mask>
<clipPath id={`${id}-handle`}>
<motion.circle cx={handleX} cy={handleY} r={RING_HANDLE / 2} />
</clipPath>
</defs>
{turns || editing ? (
<path
d={whole}
@@ -527,6 +553,51 @@ export function Ring({
)
}
/**
* The ring's dots: its stops, and what a slider reads beside its value.
*
* Drawn on the ring's centre line, inside the band, and drawn up to three
* times: on the track, again through the value arc as a stencil, and the
* reading once more through the handle. Each copy is in the colour that reads
* on what it sits on, so a dot changes colour exactly where the arc or the
* handle passes over it — and the reading still shows on the handle.
*/
function RingDots({
size,
stops = [],
reading,
}: {
size: number
stops?: number[]
reading: { x: MotionValue<number>; y: MotionValue<number> } | null
}) {
return (
<>
{stops.map((stop, index) => {
const point = ringPoint(size, stop)
return (
<circle
// Two options may share a label; position is the identity.
key={`stop-${index}`}
cx={point.x}
cy={point.y}
r={RING_MARK / 2}
className="dui-ring-stop"
/>
)
})}
{reading ? (
<motion.circle
cx={reading.x}
cy={reading.y}
r={RING_READING / 2}
className="dui-ring-reading"
/>
) : null}
</>
)
}
/**
* The colour ring's track: every hue in order along the sweep, red at both
* ends. Drawn as short strokes — an SVG stroke takes no conic gradient — each
@@ -254,6 +254,11 @@ export function useSliderDrag({
fraction,
/** Where the mark sits along the track, 0..1 and held to it; null for none. */
markAt: mark === null ? null : markAt,
/** The scale's ticks as dots in the track, 0..1, in either orientation. */
stops:
ticks && span > 0
? Array.from({ length: intervals + 1 }, (_, index) => index / intervals)
: [],
inputProps: {
type: "range" as const,
min,
@@ -412,43 +412,162 @@
translate: -50% 50%;
}
/* A second value on the track, such as what a setpoint is set against: a bar
across the track, on the handle's own travel so the two line up at the same
value, and longer than the handle so it still shows when they meet. Under
the input, so the hit area is the input's alone. */
.dui-slider-mark {
/* The colours of the dots inside a slider's track and along the ring, by what
each copy sits on: the track, the fill, the handle. A look whose handle is
not the page colour restates the last. */
.dui-slider,
.dui-ring {
--dui-stop: var(--muted-foreground);
--dui-stop-reached: color-mix(
in srgb,
var(--primary-foreground) 70%,
transparent
);
--dui-reading: var(--foreground);
--dui-reading-reached: var(--primary-foreground);
--dui-reading-on-handle: var(--foreground);
}
/*
* Dots inside the track, on its midline: a tick's stop, and what a slider reads
* beside its value — such as the room's temperature beside a setpoint — as a
* larger one in a stronger colour. Both sit on the handle's own travel, so a
* dot lines up with the handle at the same value.
*
* Drawn up to three times, each copy in the colour that reads on what it sits
* on: on the track; again over the fill, cut to the fill's extent; and the
* reading once more over the handle, cut to the handle. So a dot changes
* colour exactly where the fill or the handle passes it, as the ring's do.
* After the input only for its `:disabled`, as none of them takes a pointer.
*/
.dui-slider-dots,
.dui-slider-reached,
.dui-slider-on-thumb {
position: absolute;
background: var(--foreground);
pointer-events: none;
}
.dui-slider[data-orientation="horizontal"] .dui-slider-mark {
.dui-slider-dots {
overflow: hidden;
border-radius: 9999px;
}
.dui-slider[data-orientation="horizontal"] .dui-slider-dots {
left: 0;
right: 0;
top: 50%;
height: var(--dui-track-size, 0.5rem);
translate: 0 -50%;
}
.dui-slider[data-orientation="vertical"] .dui-slider-dots {
top: 0;
bottom: 0;
left: 50%;
width: var(--dui-track-size, 0.5rem);
translate: -50% 0;
}
.dui-slider-reached {
inset: 0;
}
.dui-slider[data-orientation="horizontal"] .dui-slider-reached {
clip-path: inset(0 calc((1 - var(--dui-fraction, 0)) * 100%) 0 0);
}
.dui-slider[data-orientation="vertical"] .dui-slider-reached {
clip-path: inset(calc((1 - var(--dui-fraction, 0)) * 100%) 0 0 0);
}
/* Over the handle, which comes before it. */
.dui-slider-on-thumb {
inset: 0;
z-index: 1;
}
.dui-slider[data-orientation="horizontal"] .dui-slider-on-thumb {
clip-path: circle(
calc(var(--dui-thumb) / 2) at
calc(
var(--dui-thumb) /
2 +
var(--dui-fraction, 0) *
(100% - var(--dui-thumb))
)
50%
);
}
.dui-slider[data-orientation="vertical"] .dui-slider-on-thumb {
clip-path: circle(
calc(var(--dui-thumb) / 2) at 50%
calc(
100% -
var(--dui-thumb) /
2 -
var(--dui-fraction, 0) *
(100% - var(--dui-thumb))
)
);
}
.dui-slider-stop,
.dui-slider-reading {
position: absolute;
border-radius: 50%;
}
.dui-slider-stop {
width: calc(var(--dui-track-size, 0.5rem) / 2);
height: calc(var(--dui-track-size, 0.5rem) / 2);
background: var(--dui-stop);
}
.dui-slider-reading {
width: calc(var(--dui-track-size, 0.5rem) * 0.75);
height: calc(var(--dui-track-size, 0.5rem) * 0.75);
background: var(--dui-reading);
}
.dui-slider-reached .dui-slider-stop {
background: var(--dui-stop-reached);
}
.dui-slider-reached .dui-slider-reading {
background: var(--dui-reading-reached);
}
.dui-slider-on-thumb .dui-slider-reading {
background: var(--dui-reading-on-handle);
}
.dui-slider[data-orientation="horizontal"]
:is(.dui-slider-stop, .dui-slider-reading) {
top: 50%;
left: calc(
var(--dui-thumb) /
2 +
var(--dui-mark, 0) *
var(--dui-at, 0) *
(100% - var(--dui-thumb))
);
width: 2px;
height: calc(var(--dui-thumb) + 0.5rem);
translate: -50% -50%;
}
.dui-slider[data-orientation="vertical"] .dui-slider-mark {
.dui-slider[data-orientation="vertical"]
:is(.dui-slider-stop, .dui-slider-reading) {
left: 50%;
bottom: calc(
var(--dui-thumb) /
2 +
var(--dui-mark, 0) *
var(--dui-at, 0) *
(100% - var(--dui-thumb))
);
width: calc(var(--dui-thumb) + 0.5rem);
height: 2px;
translate: -50% 50%;
}
.dui-slider-input:disabled ~ .dui-slider-mark {
.dui-slider-input:disabled ~ .dui-slider-dots,
.dui-slider-input:disabled ~ .dui-slider-on-thumb {
opacity: 0.45;
}
@@ -685,13 +804,25 @@
opacity: 1;
}
.dui-ring-mark {
fill: var(--muted-foreground);
/* The ring's dots, in the colours the slider's track uses for its own. */
.dui-ring-stop {
fill: var(--dui-stop);
}
/* What a slider reads beside its value, as the tile's track marks it. */
.dui-ring-reading {
stroke: var(--foreground);
fill: var(--dui-reading);
}
.dui-ring-reached .dui-ring-stop {
fill: var(--dui-stop-reached);
}
.dui-ring-reached .dui-ring-reading {
fill: var(--dui-reading-reached);
}
.dui-ring-on-handle .dui-ring-reading {
fill: var(--dui-reading-on-handle);
}
/* Page dots, one per face, in the ring's gap. The current one changes over
@@ -0,0 +1,55 @@
/**
* What a slider marks inside its track: a dot per tick, and what it reads
* beside its value as a larger one.
*
* The same markup in every look — the dots are geometry, and `core.css` draws
* them in the look's tokens — so it lives here rather than three times over.
* The copies over the fill and over the handle are what let a dot change
* colour exactly where either passes it.
*/
import { type MotionStyle, type MotionValue, motion } from "motion/react"
export function SliderMarks({
stops,
mark,
}: {
/** Where each tick sits along the track, 0..1. */
stops: number[]
/** Where the reading sits, 0..1; null for none. */
mark: MotionValue<number> | null
}) {
if (stops.length === 0 && mark === null) return null
const dots = (
<>
{stops.map((at) => (
<span
key={at}
className="dui-slider-stop"
style={{ "--dui-at": at } as React.CSSProperties}
/>
))}
{mark ? (
<motion.span
className="dui-slider-reading"
style={{ "--dui-at": mark } as MotionStyle}
/>
) : null}
</>
)
return (
<>
<span className="dui-slider-dots" aria-hidden>
{dots}
<span className="dui-slider-reached">{dots}</span>
</span>
{mark ? (
<span className="dui-slider-on-thumb" aria-hidden>
<motion.span
className="dui-slider-reading"
style={{ "--dui-at": mark } as MotionStyle}
/>
</span>
) : null}
</>
)
}
@@ -22,13 +22,12 @@ export const RING_SWEEP = 300
export const RING_STROKE = 16
/** The handle's diameter. */
export const RING_HANDLE = 28
/** A dropdown's option marks, as a diameter. */
/** A stop on the ring — a selector's option, a slider's tick — as a dot this
* wide on the ring's centre line. */
export const RING_MARK = 6
/** What a slider reads beside its value, marked on the ring: a bar across the
* band this long and this wide, longer than the handle so it still shows
* where the two meet. */
export const RING_READING = RING_HANDLE + 8
export const RING_READING_STROKE = 3
/** What a slider reads beside its value, as a larger dot on the centre line:
* told from a stop by its size and its stronger colour. */
export const RING_READING = 10
/** How long the ring waits after the last notch before it publishes. */
export const RING_SETTLE_MS = 300
/** The gap's line — a slider's title and value, when what it reads is in
@@ -26,6 +26,7 @@ import type {
} from "../core/contract"
import { useSegmented, useSliderDrag, useSwitch } from "../core/controls"
import { useLook } from "../core/look"
import { SliderMarks } from "../core/marks"
import { LOOK } from "../core/motion"
export function Button({
@@ -65,7 +66,8 @@ export function Switch(props: SwitchProps) {
}
export function Slider(props: SliderProps) {
const { fraction, inputProps, marks, markAt, trackRef } = useSliderDrag(props)
const { fraction, inputProps, marks, markAt, stops, trackRef } =
useSliderDrag(props)
const orientation = props.orientation ?? "horizontal"
return (
<motion.div
@@ -85,15 +87,7 @@ export function Slider(props: SliderProps) {
the keyboard, the pointer and every `aria-`, and owes the look
nothing. */}
<input {...inputProps} className="dui-slider-input" />
{/* Under the handle, so where the two meet the value being set wins;
after the input only for its `:disabled`, as it takes no pointer. */}
{markAt === null ? null : (
<motion.span
className="dui-slider-mark"
aria-hidden
style={{ "--dui-mark": markAt } as MotionStyle}
/>
)}
<SliderMarks stops={stops} mark={markAt} />
<span className="dui-slider-thumb" aria-hidden />
</div>
{marks.length > 0 ? (
@@ -26,6 +26,7 @@ import {
useSwitch,
} from "../core/controls"
import { useLook } from "../core/look"
import { SliderMarks } from "../core/marks"
import { LOOK } from "../core/motion"
export function Button({
@@ -76,7 +77,8 @@ export function Switch(props: SwitchProps) {
}
export function Slider(props: SliderProps) {
const { fraction, inputProps, marks, markAt, trackRef } = useSliderDrag(props)
const { fraction, inputProps, marks, markAt, stops, trackRef } =
useSliderDrag(props)
const orientation = props.orientation ?? "horizontal"
return (
<motion.div
@@ -96,15 +98,7 @@ export function Slider(props: SliderProps) {
the keyboard, the pointer and every `aria-`, and owes the look
nothing. */}
<input {...inputProps} className="dui-slider-input" />
{/* Under the handle, so where the two meet the value being set wins;
after the input only for its `:disabled`, as it takes no pointer. */}
{markAt === null ? null : (
<motion.span
className="dui-slider-mark"
aria-hidden
style={{ "--dui-mark": markAt } as MotionStyle}
/>
)}
<SliderMarks stops={stops} mark={markAt} />
<span className="dui-slider-thumb" aria-hidden />
</div>
{marks.length > 0 ? (
@@ -435,6 +435,11 @@
filter: drop-shadow(0 2px 8px rgb(0 0 0 / 0.35));
}
/* A reading's dot on the handle, which is white here in either theme. */
[data-look="glass"] :is(.dui-ring, .dui-slider) {
--dui-reading-on-handle: rgb(0 0 0 / 0.75);
}
[data-look="glass"] .dui-ring-swatch {
stroke: rgb(255 255 255 / 0.85);
stroke-width: 2px;
@@ -28,6 +28,7 @@ import {
useSwitch,
} from "../core/controls"
import { useLook } from "../core/look"
import { SliderMarks } from "../core/marks"
import { LOOK } from "../core/motion"
/** The state layer plus whatever presses are still fading. */
@@ -105,7 +106,8 @@ export function Switch(props: SwitchProps) {
}
export function Slider(props: SliderProps) {
const { fraction, inputProps, marks, markAt, trackRef } = useSliderDrag(props)
const { fraction, inputProps, marks, markAt, stops, trackRef } =
useSliderDrag(props)
const orientation = props.orientation ?? "horizontal"
return (
<motion.div
@@ -125,15 +127,7 @@ export function Slider(props: SliderProps) {
the keyboard, the pointer and every `aria-`, and owes the look
nothing. */}
<input {...inputProps} className="dui-slider-input" />
{/* Under the handle, so where the two meet the value being set wins;
after the input only for its `:disabled`, as it takes no pointer. */}
{markAt === null ? null : (
<motion.span
className="dui-slider-mark"
aria-hidden
style={{ "--dui-mark": markAt } as MotionStyle}
/>
)}
<SliderMarks stops={stops} mark={markAt} />
<span className="dui-slider-thumb" aria-hidden />
</div>
{marks.length > 0 ? (
@@ -334,6 +334,11 @@
fill: var(--primary);
}
/* A reading's dot on the handle, which is primary here. */
[data-look="material"] :is(.dui-ring, .dui-slider) {
--dui-reading-on-handle: var(--primary-foreground);
}
[data-look="material"] .dui-ring-swatch {
stroke: var(--card);
stroke-width: 3px;