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
+47 -1
View File
@@ -277,7 +277,7 @@ look's muted text colour.
| Any other type | its rectangular body, without a card, from y = c − 64 to c + S/(2√2), x = c ± S/(2√2) (Knob: 53–307 × 116–307) |
| Page dots | one per face, from two faces: dots 6 across on targets 16 apart (closer when more than 128 wide would be needed), centred at y = S − 12; the current one in the text colour, the others muted at half opacity |
| Dashboard name | a pill centred at (c, c), 36 high: the icon 16 and the title at 15, medium, at most 220 wide, on the frosted surface; for 1000 ms |
| Paging | a new face slides in 48 from the side it was paged towards and fades in; the old one leaves the other way |
| Paging | faces slide sideways, dashboards up and down; see [Motion](#motion) |
| Locked | controls do nothing and read as disabled; the ring at 0.45 opacity |
Paint follows the look: Fluksio draws a muted track, a primary value and a
@@ -285,6 +285,52 @@ handle of the ground colour ringed in primary; Material a tonal track and a
primary handle; Glass a translucent track, a glowing value and a white handle.
A Switch or Button face wears the look's own button.
## Motion
How a dashboard moves, for devices that draw it natively. Distances are canvas
pixels. Each look moves at its own spring; a device without springs runs the
spring's closed form for the duration given and then sets the end value, or
uses the cubic-bezier that stands in for it.
| Look | Spring (stiffness, damping, mass) | ζ, ω₀ | Duration (within 1 %) | Overshoot | Cubic-bezier stand-in |
|---|---|---|---|---|---|
| Fluksio | 420, 34, 1 | 0.830, 20.49 s⁻¹ | 210 ms | 0.9 % | (0.26, 0.12, 0.25, 1.01) over 250 ms, at most 1.5 % off |
| Material | 550, 38, 1 | 0.810, 23.45 s⁻¹ | 270 ms | 1.3 % | (0.20, 0.11, 0.27, 1.19) over 260 ms, at most 1.7 % off |
| Glass | 320, 22, 0.8 | 0.688, 20.00 s⁻¹ | 330 ms | 5.1 % | (0.26, 0.20, 0.20, 1.33) over 320 ms, at most 1.7 % off |
From rest, a spring's progress is p(t) = 1 − e^(−ζω₀t)·(cos ω_d t +
(ζω₀/ω_d)·sin ω_d t), with ω_d = ω₀·√(1 − ζ²). "Spring" below is the
dashboard's look's.
| What | Moves | From → to | Timing | Input during it |
|---|---|---|---|---|
| Face paging (round) | the new face and the old | new: x +48 (next) or −48 (previous), opacity 0 → x 0, opacity 1; old: x 0, opacity 1 → x −48 (next) or +48, opacity 0. Both at once | spring | another page retargets: each face heads for its new end from where it is |
| Dashboard paging (round panel) | the new dashboard's face and the old | the same on y: the next rises from +48, the previous comes down from −48. One opened by its address only fades | spring | as faces |
| Page dot | the current dot and the last | muted at 0.5 opacity ↔ text colour at 1 | 200 ms, cubic-bezier(0.4, 0, 0.2, 1) | retargets |
| Dashboard name | the pill | opacity 0 → 1, held 1000 ms, 1 → 0 | in 200 ms, out 150 ms, cubic-bezier(0.4, 0, 0.2, 1) | — |
| Ring value set by a notch, a key or the engine | the value arc's end, the handle (slider, selector, colour) | from where it is drawn to the new fraction of the sweep | spring | each notch retargets from where it is, at the speed it has |
| Ring value set by a drag | the same | follows the pointer exactly | none | — |
| Hue across red | the colour handle | a change of more than half the sweep jumps to the other end | none | — |
| Slider's reading on the ring, gauge on the ring | the reading's mark; the value arc | from where it is drawn to the new fraction | spring | retargets |
| Numbers on the ring | centre reading, gap line | switch at once, with the notch | none | — |
| Value face, Value and Gauge tiles | the number | counts to the new value, written to its precision | spring | retargets |
| Slider tile set elsewhere | the fill's end, the handle, the reading's mark | from where it is drawn to the new fraction | spring | a drag or a key moves it at once |
| Gauge and Bar tiles | the value arc, each bar's fill | from where it is drawn to the new fraction | spring | retargets |
| Dashboard change (rectangular panel) | every tile | the old dashboard goes at once; each new tile opacity 0 → 1, y +8 → 0 | spring | — |
| Rail | the current entry's marker | slides to the new entry | spring | retargets |
With reduced motion asked for, nothing travels: pages and dashboards only
cross-fade (opacity at the spring), and every value is drawn at its new place
at once. The dot and the name pill, which only change colour or opacity, keep
their timing.
A device may simplify where its hardware is slow, keeping the direction and
the end state: a page may slide without the fade, fade without the slide, or
cut; a retarget may restart the curve from where it is without carrying the
speed; a counting number may switch; and the value arc may be redrawn each
frame as an arc to the eased angle, which is what the browser's reveal
amounts to.
## Showing one
Open `/view/{name}` for a dashboard-only view with your normal session.
@@ -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>
)
}