Dashboard motion: round dashboards page up and down, the ring eases to a notch or a reading, the slider tile to a value set elsewhere; reduced motion honoured by every tween; motion spec in the docs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Melvin Strobl
2026-10-14 16:34:10 +02:00
co-authored by Claude Opus 5.5
parent 5b2769c141
commit b500af84ec
11 changed files with 245 additions and 91 deletions
@@ -4,7 +4,7 @@ import type { DashboardDef_Output, Placement, WidgetDef } from "@/client"
import { cn } from "@/lib/utils"
import "./dashboard.css"
import { RoundView } from "./RoundFace"
import { RoundPanel } from "./RoundFace"
import {
LockedProvider,
PaletteProvider,
@@ -393,8 +393,7 @@ export function DashboardView({
{round ? (
// Its own face memory per dashboard, so a panel switching to another
// starts that one where it was left.
<RoundView
key={dashboard.name}
<RoundPanel
dashboard={dashboard}
size={canvasOf(dashboard).width}
onDashboard={onDashboard}
@@ -43,7 +43,7 @@ import {
TITLE_WIDTH,
useRingDraft,
} from "./ui/core/ring"
import { useAnimatedFraction } from "./ui/core/values"
import { useEased } from "./ui/core/values"
import type { Series } from "./widgets"
/** How much wheel a notch is: what one detent of a mouse wheel reports. */
@@ -242,7 +242,8 @@ export function CentreReading({
* which is the one thing a knob's ring can set and a circle's edge can show.
* Turned by a drag along it or by the mouse wheel, a notch a step, an option
* or a hue step; the value moves at once and one publish follows once the
* turning stops (`useRingDraft`). Its reading is written large in the middle,
* turning stops (`useRingDraft`). What is drawn along the ring eases to a
* notch or a reading at the look's spring, and follows a drag exactly. Its reading is written large in the middle,
* unless a slider reads something beside it: then that is in the middle, and
* the slider's own title and value move to the gap's line.
*
@@ -272,16 +273,21 @@ export function Ring({
widget.type === "slider" ? ((cfg.reading ?? {}) as Series) : {}
const read = useBoundValue(beside.message || undefined)
const dial = dialOf(widget, widget.type === "gauge" ? bound?.value : value)
const { current, latest, move, commit } = useRingDraft(dial.value, (next) =>
send(dial.wire(next)),
const { current, held, latest, move, commit } = useRingDraft(
dial.value,
(next) => send(dial.wire(next)),
)
const svg = useRef<SVGSVGElement>(null)
const face = useRef<HTMLDivElement>(null)
const spin = useRef(0)
const turns = Boolean(dial.notch) && !locked
const at = dial.at(current)
const animated = useAnimatedFraction(at ?? 0)
const shown = useTransform(animated, (fraction) => (fraction > 0 ? 1 : 0))
const along = useEased(at ?? 0, held, Boolean(dial.hue))
// At the minimum the value arc is a zero-length dash whose round caps would
// still paint a dot, so it is not drawn there.
const shown = useTransform(along, (fraction) => (fraction > 0 ? 1 : 0))
const handleX = useTransform(along, (fraction) => ringPoint(size, fraction).x)
const handleY = useTransform(along, (fraction) => ringPoint(size, fraction).y)
const turn = (by: number) => {
if (turns && dial.notch) move(dial.notch(latest(), by), true)
@@ -327,17 +333,14 @@ 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
const handle = dial.handle && at !== null ? ringPoint(size, at) : 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.
const actual = typeof read?.value === "number" ? dial.at(read.value) : null
const across =
actual === null
? null
: [
ringPoint(size, actual, -RING_READING / 2),
ringPoint(size, actual, RING_READING / 2),
]
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 whole = ringArc(size, 0, 1)
const name = widget.title || text(cfg.target, widget.id)
@@ -394,26 +397,16 @@ export function Ring({
className="dui-ring-track"
/>
)}
{/* A slider's reading follows the hand, so it is drawn where it
is. A gauge eases to its reading, revealed rather than re-pathed
as the gauge tile's arc is, and transparent at the minimum where
its round caps would still paint a dot. */}
{dial.fill && dial.notch && at ? (
<path
d={ringArc(size, 0, at)}
fill="none"
strokeWidth={RING_STROKE}
strokeLinecap="round"
className="dui-ring-fill"
/>
) : dial.fill && !dial.notch ? (
{/* 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: animated, opacity: shown }}
style={{ pathLength: along, opacity: shown }}
/>
) : null}
{dial.marks?.map((mark, index) => {
@@ -430,20 +423,17 @@ export function Ring({
)
})}
{/* Under the handle, so where the two meet the value being set wins. */}
{across ? (
<line
x1={across[0].x}
y1={across[0].y}
x2={across[1].x}
y2={across[1].y}
{actual !== null ? (
<motion.path
d={across}
strokeWidth={RING_READING_STROKE}
className="dui-ring-reading"
/>
) : null}
{handle ? (
<circle
cx={handle.x}
cy={handle.y}
{dial.handle && at !== null ? (
<motion.circle
cx={handleX}
cy={handleY}
r={RING_HANDLE / 2}
className={cn("dui-ring-handle", dial.hue && "dui-ring-swatch")}
style={dial.hue ? { fill: cssOf(current as Triple) } : undefined}
+86 -10
View File
@@ -1,9 +1,15 @@
import { Power } from "lucide-react"
import { AnimatePresence, motion, type Variants } from "motion/react"
import {
AnimatePresence,
motion,
useIsPresent,
type Variants,
} from "motion/react"
import { useEffect, useRef, useState } from "react"
import { ErrorBoundary } from "react-error-boundary"
import type { WidgetDef } from "@/client"
import { fadeIn } from "@/lib/motion"
import { useShortcuts } from "@/lib/shortcuts"
import { useClock } from "./ClockWidget"
import type { Dashboard } from "./DashboardView"
@@ -70,11 +76,77 @@ const lastFace = new Map<string, number>()
/** The dashboard a panel showed last, so landing on another can say which. */
let lastShown: string | null = null
/** How far a face or a dashboard slides in as it is paged to, in canvas
* pixels, fading in as it comes. */
const PAGE_SLIDE = 48
/** A face arrives from the side it was paged towards. */
const TURN: Variants = {
enter: (way: number) => ({ opacity: 0, x: way * 48 }),
enter: (way: number) => ({ opacity: 0, x: way * PAGE_SLIDE }),
shown: { opacity: 1, x: 0 },
leave: (way: number) => ({ opacity: 0, x: way * -48 }),
leave: (way: number) => ({ opacity: 0, x: way * -PAGE_SLIDE }),
}
/** And a dashboard from below or above, as the faces do from the side. */
const RISE: Variants = {
enter: (way: number) => ({ opacity: 0, y: way * PAGE_SLIDE }),
shown: { opacity: 1, y: 0 },
leave: (way: number) => ({ opacity: 0, y: way * -PAGE_SLIDE }),
}
/**
* A panel's round dashboards, one at a time, paged up and down.
*
* The next one rises in from below as the last leaves upwards — the way a
* swipe up moved them — and the previous one comes down from above. One that
* arrives without a turn, by its address, only fades in.
*/
export function RoundPanel({
dashboard,
size,
onDashboard,
}: {
dashboard: Dashboard
size: number
onDashboard?: (by: number) => void
}) {
const { look } = useLook()
// Read between renders: the turn asked for, and the side the dashboard it
// brings arrives from once it has loaded.
const turned = useRef(0)
const shown = useRef(dashboard.name)
const way = useRef(0)
if (shown.current !== dashboard.name) {
shown.current = dashboard.name
way.current = turned.current
turned.current = 0
}
return (
<AnimatePresence initial={false} custom={way.current}>
<motion.div
key={dashboard.name}
custom={way.current}
variants={RISE}
initial="enter"
animate="shown"
exit="leave"
transition={LOOK[look].spring}
className="absolute inset-0"
>
<RoundView
dashboard={dashboard}
size={size}
onDashboard={
onDashboard &&
((by) => {
turned.current = by
onDashboard(by)
})
}
/>
</motion.div>
</AnimatePresence>
)
}
/**
@@ -124,6 +196,8 @@ export function RoundView({
was.current = index
}
const swiped = useRef(false)
// A dashboard on its way out keeps its keys to itself.
const present = useIsPresent()
const [announce, setAnnounce] = useState(
() =>
Boolean(onDashboard) &&
@@ -148,11 +222,12 @@ export function RoundView({
onFace?.(next)
}
const keys = !editing && present
useShortcuts({
pagedown: () => (editing ? false : page(index + 1)),
pageup: () => (editing ? false : page(index - 1)),
"shift+pagedown": () => (onDashboard && !editing ? onDashboard(1) : false),
"shift+pageup": () => (onDashboard && !editing ? onDashboard(-1) : false),
pagedown: () => (keys ? page(index + 1) : false),
pageup: () => (keys ? page(index - 1) : false),
"shift+pagedown": () => (onDashboard && keys ? onDashboard(1) : false),
"shift+pageup": () => (onDashboard && keys ? onDashboard(-1) : false),
})
/** A drag that ends far enough away is a swipe, measured in canvas pixels
@@ -270,9 +345,10 @@ export function RoundView({
{announce ? (
<motion.div
key="announce"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
variants={fadeIn}
initial="hidden"
animate="visible"
exit="exit"
className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center"
>
<output
@@ -8,6 +8,7 @@
import { useCallback, useEffect, useId, useRef, useState } from "react"
import { fractionOf } from "./config"
import { useEased } from "./values"
// ---------------------------------------------------------------------------
// Press
@@ -173,7 +174,8 @@ export function fitMarks<T extends { label: string }>(
* A drag would otherwise send a value per pixel and flood whatever is
* listening, so the draft follows the finger and only the release publishes.
* While there is no draft the handle follows the engine, which is what makes a
* value set elsewhere show up here.
* value set elsewhere show up here — easing there, as a reading does, where a
* drag or a key moves it at once.
*/
export function useSliderDrag({
value,
@@ -242,13 +244,16 @@ export function useSliderDrag({
}))
: []
const fraction = useEased(fractionOf(current, min, max), draft !== null)
const markAt = useEased(mark === null ? 0 : fractionOf(mark, min, max))
return {
current,
/** Goes on the slider, whose width is the scale's width. */
trackRef,
fraction: fractionOf(current, min, max),
fraction,
/** Where the mark sits along the track, 0..1 and held to it; null for none. */
markAt: mark === null ? null : fractionOf(mark, min, max),
markAt: mark === null ? null : markAt,
inputProps: {
type: "range" as const,
min,
@@ -694,11 +694,15 @@
stroke: var(--foreground);
}
/* Page dots, one per face, in the ring's gap. */
/* Page dots, one per face, in the ring's gap. The current one changes over
as the face slides in: a colour, so reduced motion keeps it. */
.dui-dot-mark {
border-radius: 50%;
background: var(--muted-foreground);
opacity: 0.5;
transition:
background-color var(--duration-base) var(--ease-standard),
opacity var(--duration-base) var(--ease-standard);
}
.dui-dot[aria-current="page"] .dui-dot-mark {
@@ -132,7 +132,7 @@ export function fractionAt(dx: number, dy: number): number {
* a draft the ring follows the engine, through the caller's hold-until-echo.
*/
export function useRingDraft<T>(value: T, send: (value: T) => void) {
const [draft, setDraft] = useState<{ value: T } | null>(null)
const [draft, setDraft] = useState<{ value: T; settle: boolean } | null>(null)
// Read between renders: notches arrive faster than React draws, and each
// one steps from the last rather than from what was last drawn.
const pending = useRef<{ value: T } | null>(null)
@@ -155,12 +155,14 @@ export function useRingDraft<T>(value: T, send: (value: T) => void) {
return {
current: draft ? draft.value : value,
/** A drag is moving it, which the drawing follows without easing. */
held: draft !== null && !draft.settle,
/** The value a further notch steps from. */
latest: () => (pending.current ? pending.current.value : value),
/** Move the draft; `settle` publishes once nothing has moved it for a while. */
move: (next: T, settle: boolean) => {
pending.current = { value: next }
setDraft({ value: next })
setDraft({ value: next, settle })
if (timer.current) clearTimeout(timer.current)
timer.current = settle ? setTimeout(commit, RING_SETTLE_MS) : null
},
@@ -3,16 +3,20 @@
*
* A number that jumps is hard to read and a number that crawls is a distraction
* — both sets tween theirs, at their own look's stiffness, through the motion
* values here. `<MotionConfig reducedMotion="user">` at the app root switches
* every one of them off when the device asks.
* values here. These are motion values rather than animated props, which
* `<MotionConfig reducedMotion="user">` never reaches, so `useEased` asks the
* device itself and moves at once when it wants less motion.
*/
import {
animate,
cancelFrame,
frame,
type MotionValue,
useMotionValue,
useSpring,
useReducedMotion,
useTransform,
} from "motion/react"
import { useEffect } from "react"
import { useLayoutEffect, useRef } from "react"
import type { WidgetDef } from "@/client"
import { CHART_SLOTS } from "@/components/Common/UplotChart"
@@ -43,14 +47,12 @@ export function useAnimatedNumber(
value: unknown,
precision: number | null,
): { label: MotionValue<string>; numeric: boolean } {
const { look } = useLook()
const numeric = typeof value === "number"
const raw = useMotionValue(numeric ? value : 0)
const settled = useSpring(raw, LOOK[look].spring)
// A reading that goes away and comes back tweens from where it last was.
const last = useRef(numeric ? value : 0)
if (numeric) last.current = value
const settled = useEased(last.current)
const written = precision ?? (numeric ? decimalsOf(value as number) : null)
useEffect(() => {
if (numeric) raw.set(value as number)
}, [numeric, value, raw])
return {
label: useTransform(settled, (at) => format(at, written)),
numeric,
@@ -59,12 +61,42 @@ export function useAnimatedNumber(
/** Where a reading sits on its scale, tweened, for a fill or an arc. */
export function useAnimatedFraction(fraction: number): MotionValue<number> {
return useEased(fraction)
}
/**
* A number on its way to `value`, at the look's spring.
*
* A new value retargets a move still running, from where it is and as fast as
* it was going. It moves at once instead while a hand is on it (`held`: a drag
* stays under the finger), when the device asks for reduced motion, and for a
* value that `wraps`, a hue, when it crosses the seam — more than half the
* range, which would otherwise sweep back along the whole of it.
*/
export function useEased(
value: number,
held = false,
wraps = false,
): MotionValue<number> {
const { look } = useLook()
const raw = useMotionValue(fraction)
useEffect(() => {
raw.set(fraction)
}, [fraction, raw])
return useSpring(raw, LOOK[look].spring)
const still = useReducedMotion()
const eased = useMotionValue(value)
// Before paint, so a drag is drawn in the frame it moved in.
useLayoutEffect(() => {
if (held || still || (wraps && Math.abs(value - eased.get()) > 0.5)) {
// In the frame about to be drawn rather than now: what the reader
// derives from this value re-subscribes after this effect, and would
// miss a change made in it.
const jump = () => eased.jump(value)
frame.preRender(jump)
return () => cancelFrame(jump)
}
const move = animate(eased, value, LOOK[look].spring)
// Stopped rather than left running when the reader goes away; a value
// that changes again retargets instead, from inside `animate`.
return () => move.stop()
}, [value, held, wraps, still, look, eased])
return eased
}
/**
@@ -12,7 +12,7 @@
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown } from "lucide-react"
import { motion } from "motion/react"
import { type MotionStyle, motion } from "motion/react"
import { cn } from "@/lib/utils"
import { asOriginal, text } from "../core/config"
@@ -68,14 +68,14 @@ export function Slider(props: SliderProps) {
const { fraction, inputProps, marks, markAt, trackRef } = useSliderDrag(props)
const orientation = props.orientation ?? "horizontal"
return (
<div
<motion.div
className={cn(
"dui-slider fx-slider",
orientation === "horizontal" && "w-full",
)}
data-orientation={orientation}
ref={trackRef}
style={{ "--dui-fraction": fraction } as React.CSSProperties}
style={{ "--dui-fraction": fraction } as MotionStyle}
>
<div className="dui-slider-body">
<span className="dui-slider-rail" aria-hidden>
@@ -88,10 +88,10 @@ export function Slider(props: SliderProps) {
{/* 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 : (
<span
<motion.span
className="dui-slider-mark"
aria-hidden
style={{ "--dui-mark": markAt } as React.CSSProperties}
style={{ "--dui-mark": markAt } as MotionStyle}
/>
)}
<span className="dui-slider-thumb" aria-hidden />
@@ -115,7 +115,7 @@ export function Slider(props: SliderProps) {
))}
</div>
) : null}
</div>
</motion.div>
)
}
@@ -7,7 +7,7 @@
*/
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown } from "lucide-react"
import { motion } from "motion/react"
import { type MotionStyle, motion } from "motion/react"
import { cn } from "@/lib/utils"
import { asOriginal, text } from "../core/config"
@@ -79,14 +79,14 @@ export function Slider(props: SliderProps) {
const { fraction, inputProps, marks, markAt, trackRef } = useSliderDrag(props)
const orientation = props.orientation ?? "horizontal"
return (
<div
<motion.div
className={cn(
"dui-slider gl-slider",
orientation === "horizontal" && "w-full",
)}
data-orientation={orientation}
ref={trackRef}
style={{ "--dui-fraction": fraction } as React.CSSProperties}
style={{ "--dui-fraction": fraction } as MotionStyle}
>
<div className="dui-slider-body">
<span className="dui-slider-rail" aria-hidden>
@@ -99,10 +99,10 @@ export function Slider(props: SliderProps) {
{/* 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 : (
<span
<motion.span
className="dui-slider-mark"
aria-hidden
style={{ "--dui-mark": markAt } as React.CSSProperties}
style={{ "--dui-mark": markAt } as MotionStyle}
/>
)}
<span className="dui-slider-thumb" aria-hidden />
@@ -126,7 +126,7 @@ export function Slider(props: SliderProps) {
))}
</div>
) : null}
</div>
</motion.div>
)
}
@@ -8,7 +8,7 @@
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown } from "lucide-react"
import { AnimatePresence, motion } from "motion/react"
import { AnimatePresence, type MotionStyle, motion } from "motion/react"
import { cn } from "@/lib/utils"
import { asOriginal, text } from "../core/config"
@@ -108,14 +108,14 @@ export function Slider(props: SliderProps) {
const { fraction, inputProps, marks, markAt, trackRef } = useSliderDrag(props)
const orientation = props.orientation ?? "horizontal"
return (
<div
<motion.div
className={cn(
"dui-slider m3-slider",
orientation === "horizontal" && "w-full",
)}
data-orientation={orientation}
ref={trackRef}
style={{ "--dui-fraction": fraction } as React.CSSProperties}
style={{ "--dui-fraction": fraction } as MotionStyle}
>
<div className="dui-slider-body">
<span className="dui-slider-rail" aria-hidden>
@@ -128,10 +128,10 @@ export function Slider(props: SliderProps) {
{/* 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 : (
<span
<motion.span
className="dui-slider-mark"
aria-hidden
style={{ "--dui-mark": markAt } as React.CSSProperties}
style={{ "--dui-mark": markAt } as MotionStyle}
/>
)}
<span className="dui-slider-thumb" aria-hidden />
@@ -155,7 +155,7 @@ export function Slider(props: SliderProps) {
))}
</div>
) : null}
</div>
</motion.div>
)
}