Skip to content

Commit afe1fff

Browse files
committed
refactor(parameter-slider): switch to CSS-only motion smoothing
1 parent 4cefc70 commit afe1fff

File tree

5 files changed

+47
-126
lines changed

5 files changed

+47
-126
lines changed

components/tool-ui/parameter-slider/math.ts

Lines changed: 0 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,11 @@ type SliderPercentInput = {
66
max: number;
77
};
88

9-
type EasedSliderPercentInput = {
10-
current: number;
11-
target: number;
12-
isDragging: boolean;
13-
};
14-
159
function clampPercent(value: number): number {
1610
if (!Number.isFinite(value)) return 0;
1711
return Math.max(0, Math.min(100, value));
1812
}
1913

20-
const DRAG_EASING_FACTOR = 0.42;
21-
const IDLE_EASING_FACTOR = 0.24;
22-
const EASING_SNAP_THRESHOLD = 0.02;
23-
2414
export function sliderRangeToPercent({
2515
value,
2616
min,
@@ -31,23 +21,6 @@ export function sliderRangeToPercent({
3121
return clampPercent(((value - min) / range) * 100);
3222
}
3323

34-
export function advanceEasedSliderPercent({
35-
current,
36-
target,
37-
isDragging,
38-
}: EasedSliderPercentInput): number {
39-
const safeCurrent = clampPercent(current);
40-
const safeTarget = clampPercent(target);
41-
const delta = safeTarget - safeCurrent;
42-
43-
if (Math.abs(delta) <= EASING_SNAP_THRESHOLD) {
44-
return safeTarget;
45-
}
46-
47-
const easingFactor = isDragging ? DRAG_EASING_FACTOR : IDLE_EASING_FACTOR;
48-
return safeCurrent + delta * easingFactor;
49-
}
50-
5124
export function createSliderValueSnapshot(sliders: SliderConfig[]): SliderValue[] {
5225
return sliders.map((slider) => ({ id: slider.id, value: slider.value }));
5326
}

components/tool-ui/parameter-slider/parameter-slider.tsx

Lines changed: 25 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ import { useSignatureReset } from "../shared/use-signature-reset";
2121

2222
import { cn } from "./_adapter";
2323
import {
24-
advanceEasedSliderPercent,
2524
createSliderSignature,
2625
createSliderValueSnapshot,
2726
sliderRangeToPercent,
@@ -406,39 +405,6 @@ function SliderRow({
406405
? sliderRangeToPercent({ value: 0, min, max })
407406
: 0;
408407
const valuePercent = sliderRangeToPercent({ value, min, max });
409-
const [easedValuePercent, setEasedValuePercent] = useState(valuePercent);
410-
const easedValuePercentRef = useRef(valuePercent);
411-
412-
useEffect(() => {
413-
let animationFrameId = 0;
414-
415-
const tick = () => {
416-
const next = advanceEasedSliderPercent({
417-
current: easedValuePercentRef.current,
418-
target: valuePercent,
419-
isDragging,
420-
});
421-
422-
easedValuePercentRef.current = next;
423-
setEasedValuePercent(next);
424-
425-
if (next !== valuePercent) {
426-
animationFrameId = window.requestAnimationFrame(tick);
427-
}
428-
};
429-
430-
animationFrameId = window.requestAnimationFrame(tick);
431-
432-
return () => {
433-
window.cancelAnimationFrame(animationFrameId);
434-
};
435-
}, [valuePercent, isDragging]);
436-
437-
const handleAlignmentOffset = useMemo(
438-
() =>
439-
`calc(${toRadixThumbPosition(easedValuePercent)} - ${toRadixThumbPosition(valuePercent)})`,
440-
[easedValuePercent, valuePercent],
441-
);
442408

443409
// Fill clip-path uses the same inset coordinate system as the handle.
444410
// This keeps the collapsed stroke aligned with the fill edge near extremes.
@@ -448,34 +414,33 @@ function SliderRow({
448414
const toClipFromLeftInset = (percent: number) =>
449415
toRadixThumbPosition(percent);
450416
const TERMINAL_EPSILON = 1e-6;
451-
const fillPercent = easedValuePercent;
452417

453418
if (crossesZero) {
454419
// Keep interior fill aligned to Radix thumb math, but snap to exact
455420
// track borders at terminal values to avoid edge gaps.
456-
if (fillPercent <= TERMINAL_EPSILON) {
421+
if (valuePercent <= TERMINAL_EPSILON) {
457422
return `inset(0 ${toClipFromRightInset(zeroPercent)} 0 0)`;
458423
}
459-
if (fillPercent >= 100 - TERMINAL_EPSILON) {
424+
if (valuePercent >= 100 - TERMINAL_EPSILON) {
460425
return `inset(0 0 0 ${toClipFromLeftInset(zeroPercent)})`;
461426
}
462-
if (fillPercent >= zeroPercent) {
427+
if (valuePercent >= zeroPercent) {
463428
// Positive: clip from zero on left, value on right
464-
return `inset(0 ${toClipFromRightInset(fillPercent)} 0 ${toClipFromLeftInset(zeroPercent)})`;
429+
return `inset(0 ${toClipFromRightInset(valuePercent)} 0 ${toClipFromLeftInset(zeroPercent)})`;
465430
} else {
466431
// Negative: clip from value on left, zero on right
467-
return `inset(0 ${toClipFromRightInset(zeroPercent)} 0 ${toClipFromLeftInset(fillPercent)})`;
432+
return `inset(0 ${toClipFromRightInset(zeroPercent)} 0 ${toClipFromLeftInset(valuePercent)})`;
468433
}
469434
}
470435
// Non-crossing: keep Radix alignment internally, but snap to exact borders at terminals.
471-
if (fillPercent <= TERMINAL_EPSILON) {
436+
if (valuePercent <= TERMINAL_EPSILON) {
472437
return "inset(0 100% 0 0)";
473438
}
474-
if (fillPercent >= 100 - TERMINAL_EPSILON) {
439+
if (valuePercent >= 100 - TERMINAL_EPSILON) {
475440
return "inset(0 0 0 0)";
476441
}
477-
return `inset(0 ${toClipFromRightInset(fillPercent)} 0 0)`;
478-
}, [crossesZero, easedValuePercent, zeroPercent]);
442+
return `inset(0 ${toClipFromRightInset(valuePercent)} 0 0)`;
443+
}, [crossesZero, zeroPercent, valuePercent]);
479444

480445
const fillMaskImage = crossesZero
481446
? "linear-gradient(to right, rgba(0,0,0,0.2) 0%, rgba(0,0,0,0.35) 50%, rgba(0,0,0,0.7) 100%)"
@@ -486,12 +451,11 @@ function SliderRow({
486451
const reflectionStyle = useMemo(() => {
487452
const edgeThreshold = 3;
488453
const nearEdge =
489-
easedValuePercent <= edgeThreshold ||
490-
easedValuePercent >= 100 - edgeThreshold;
454+
valuePercent <= edgeThreshold || valuePercent >= 100 - edgeThreshold;
491455

492456
// Narrower spread when stationary at edges (~35% narrower)
493457
const spreadPercent = nearEdge && !isDragging ? 6.5 : 10;
494-
const handlePos = toRadixThumbPosition(easedValuePercent);
458+
const handlePos = toRadixThumbPosition(valuePercent);
495459
const start = `clamp(0%, calc(${handlePos} - ${spreadPercent}%), 100%)`;
496460
const end = `clamp(0%, calc(${handlePos} + ${spreadPercent}%), 100%)`;
497461

@@ -508,14 +472,13 @@ function SliderRow({
508472
maskComposite: "exclude",
509473
padding: "1px",
510474
};
511-
}, [easedValuePercent, isDragging]);
475+
}, [valuePercent, isDragging]);
512476

513477
// Opacity scales with handle size: rest → hover → drag
514478
const reflectionOpacity = useMemo(() => {
515479
const edgeThreshold = 3;
516480
const atEdge =
517-
easedValuePercent <= edgeThreshold ||
518-
easedValuePercent >= 100 - edgeThreshold;
481+
valuePercent <= edgeThreshold || valuePercent >= 100 - edgeThreshold;
519482

520483
if (isDragging || atEdge) {
521484
return 1;
@@ -524,7 +487,7 @@ function SliderRow({
524487
return 0.6;
525488
}
526489
return 0;
527-
}, [easedValuePercent, isDragging, isHovered]);
490+
}, [valuePercent, isDragging, isHovered]);
528491

529492
const handleValueChange = useCallback(
530493
(values: number[]) => {
@@ -542,6 +505,8 @@ function SliderRow({
542505
className={cn(
543506
"group/slider relative flex w-full touch-none items-center select-none",
544507
"isolate h-12",
508+
"[&>span]:transition-[left,transform] [&>span]:duration-150 [&>span]:ease-[var(--cubic-ease-in-out)]",
509+
"[&>span]:will-change-[left,transform]",
545510
disabled && "pointer-events-none opacity-50",
546511
)}
547512
value={[value]}
@@ -567,7 +532,7 @@ function SliderRow({
567532
>
568533
<div
569534
className={cn(
570-
"absolute inset-0",
535+
"absolute inset-0 transition-[clip-path] duration-150 ease-[var(--cubic-ease-in-out)] will-change-[clip-path]",
571536
resolvedFillClassName ?? "bg-primary/30 dark:bg-primary/40",
572537
)}
573538
style={{
@@ -605,7 +570,7 @@ function SliderRow({
605570

606571
{/* Metallic reflection overlay - follows handle, brightness scales with interaction */}
607572
<div
608-
className="squircle pointer-events-none absolute inset-0 rounded-sm transition-[opacity] duration-200 ease-[var(--cubic-ease-in-out)]"
573+
className="squircle pointer-events-none absolute inset-0 rounded-sm transition-[opacity,background] duration-150 ease-[var(--cubic-ease-in-out)]"
609574
style={{
610575
...reflectionStyle,
611576
opacity: reflectionOpacity,
@@ -630,15 +595,15 @@ function SliderRow({
630595
// Calculate morph state
631596
const isActive = isHovered || isDragging;
632597

633-
// Move the visual handle toward the eased position so handle and fill
634-
// animate together while preserving Radix hit-target behavior.
635-
const fillEdgeOffset = handleAlignmentOffset;
598+
// Indicator stays centered on the real thumb while CSS transitions
599+
// smooth thumb wrapper and fill movement together.
600+
const fillEdgeOffset = 0;
636601

637602
// Hide rest-state indicator at edges (0% or 100%) - the reflection gradient handles this
638603
const edgeThreshold = 3;
639604
const atEdge =
640-
easedValuePercent <= edgeThreshold ||
641-
easedValuePercent >= 100 - edgeThreshold;
605+
valuePercent <= edgeThreshold ||
606+
valuePercent >= 100 - edgeThreshold;
642607
const restOpacity = atEdge ? 0 : 0.25;
643608

644609
// Asymmetric segment heights: gap is shifted up to match raised text position
@@ -667,7 +632,7 @@ function SliderRow({
667632
resolvedHandleClassName ?? "bg-primary",
668633
)}
669634
style={{
670-
transform: `translateX(calc(-50% + ${fillEdgeOffset}))`,
635+
transform: `translateX(calc(-50% + ${fillEdgeOffset}px))`,
671636
height: topHeight,
672637
opacity: isActive ? 1 : restOpacity,
673638
}}
@@ -685,7 +650,7 @@ function SliderRow({
685650
resolvedHandleClassName ?? "bg-primary",
686651
)}
687652
style={{
688-
transform: `translateX(calc(-50% + ${fillEdgeOffset}))`,
653+
transform: `translateX(calc(-50% + ${fillEdgeOffset}px))`,
689654
height: bottomHeight,
690655
opacity: isActive ? 1 : restOpacity,
691656
}}
Lines changed: 0 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { describe, expect, test } from "vitest";
22

33
import {
4-
advanceEasedSliderPercent,
54
createSliderSignature,
65
sliderRangeToPercent,
76
} from "@/components/tool-ui/parameter-slider/math";
@@ -25,40 +24,4 @@ describe("parameter-slider math contract", () => {
2524

2625
expect(a).not.toBe(b);
2726
});
28-
29-
test("easing step moves toward target without overshoot", () => {
30-
const next = advanceEasedSliderPercent({
31-
current: 20,
32-
target: 80,
33-
isDragging: false,
34-
});
35-
36-
expect(next).toBeGreaterThan(20);
37-
expect(next).toBeLessThan(80);
38-
});
39-
40-
test("dragging uses a stronger easing step than idle", () => {
41-
const idleNext = advanceEasedSliderPercent({
42-
current: 20,
43-
target: 80,
44-
isDragging: false,
45-
});
46-
const dragNext = advanceEasedSliderPercent({
47-
current: 20,
48-
target: 80,
49-
isDragging: true,
50-
});
51-
52-
expect(dragNext).toBeGreaterThan(idleNext);
53-
});
54-
55-
test("easing snaps to target when close enough", () => {
56-
const next = advanceEasedSliderPercent({
57-
current: 49.99,
58-
target: 50,
59-
isDragging: false,
60-
});
61-
62-
expect(next).toBe(50);
63-
});
6427
});

lib/tests/tool-ui/parameter-slider/visual-alignment-contract.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,4 +143,24 @@ describe("parameter-slider visual alignment contract", () => {
143143
expect(html).not.toContain("[text-shadow:");
144144
expect(html).not.toContain("text-shadow:");
145145
});
146+
147+
test("uses CSS transitions for fill and thumb movement smoothing", () => {
148+
const html = renderToStaticMarkup(
149+
React.createElement(ParameterSlider, {
150+
id: "parameter-slider-css-easing-test",
151+
sliders: [
152+
{
153+
id: "s",
154+
label: "S",
155+
min: 0,
156+
max: 100,
157+
value: 42,
158+
},
159+
],
160+
}),
161+
);
162+
163+
expect(html).toContain("transition-[clip-path]");
164+
expect(html).toContain("[&amp;&gt;span]:transition-[left,transform]");
165+
});
146166
});

public/r/parameter-slider.json

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)