Skip to content

Commit 47d3246

Browse files
samejrclaude
andcommitted
feat(webapp): mark the default contrast on the slider
Ticks the track at 20% and swaps the handle's label from a percentage to "Default" when it lands there. The tick uses Radix's own thumb-offset formula so it sits exactly under the handle's centre rather than a few pixels off. The label also gains an arrow pointing down at the handle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 41a9789 commit 47d3246

2 files changed

Lines changed: 36 additions & 2 deletions

File tree

apps/webapp/app/components/primitives/Slider.tsx

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const variants = {
1616
// light track
1717
thumb:
1818
"h-4.5 w-4.5 border border-border-bright bg-white shadow-sm dark:border-transparent dark:bg-charcoal-200 dark:shadow-none",
19+
thumbSize: 18,
1920
},
2021
tertiary: {
2122
container: "h-6 gap-1 rounded-sm hover:bg-background-raised px-1",
@@ -25,6 +26,7 @@ const variants = {
2526
range: "bg-transparent group-hover:bg-secondary",
2627
thumb:
2728
"h-3 w-3 border-2 border-text-dimmed bg-grid-bright shadow-[0_1px_3px_4px_rgb(0_0_0/0.2),0_1px_2px_-1px_rgb(0_0_0/0.1)] hover:border-text-dimmed focus:shadow-[0_1px_3px_4px_rgb(0_0_0/0.2),0_1px_2px_-1px_rgb(0_0_0/0.1)]",
29+
thumbSize: 12,
2830
},
2931
};
3032

@@ -40,6 +42,8 @@ export type SliderProps = ComponentProps<typeof RadixSlider.Root> & {
4042
* tracks the handle exactly. Reads the controlled `value`, so pass one.
4143
*/
4244
valueTooltip?: (value: number) => string;
45+
/** Values to tick on the track, e.g. the setting's default. */
46+
marks?: number[];
4347
};
4448

4549
export function Slider({
@@ -49,13 +53,16 @@ export function Slider({
4953
TrailingIcon,
5054
"aria-label": ariaLabel,
5155
valueTooltip,
56+
marks,
5257
...props
5358
}: SliderProps) {
5459
const variation = variants[variant];
5560
// The pointer leaves the thumb while dragging, so hover alone can't keep the
5661
// label up.
5762
const [isDragging, setIsDragging] = useState(false);
5863
const currentValue = props.value?.[0] ?? props.defaultValue?.[0] ?? 0;
64+
const min = props.min ?? 0;
65+
const max = props.max ?? 100;
5966

6067
return (
6168
<div className={cn("group flex items-center", variation.container)}>
@@ -83,6 +90,23 @@ export function Slider({
8390
<RadixSlider.Track className={cn("relative grow rounded-full", variation.track)}>
8491
<RadixSlider.Range className={cn("absolute h-full rounded-full", variation.range)} />
8592
</RadixSlider.Track>
93+
{marks?.map((mark) => {
94+
const percent = ((mark - min) / (max - min)) * 100;
95+
if (!Number.isFinite(percent) || percent < 0 || percent > 100) return null;
96+
// Radix keeps the thumb inside the track by offsetting it against its
97+
// own width, so a plain percentage would sit off the handle. Same
98+
// formula as its `getThumbInBoundsOffset`, so the tick lands under
99+
// the thumb's centre.
100+
const offset = variation.thumbSize * (0.5 - percent / 100);
101+
return (
102+
<span
103+
key={mark}
104+
aria-hidden
105+
className="absolute top-1/2 h-2 w-px -translate-x-1/2 -translate-y-1/2 bg-text-dimmed"
106+
style={{ left: `calc(${percent}% + ${offset}px)` }}
107+
/>
108+
);
109+
})}
86110
{/* The thumb is the role="slider" element, so the label lives here */}
87111
<RadixSlider.Thumb
88112
aria-label={ariaLabel}
@@ -94,13 +118,16 @@ export function Slider({
94118
{valueTooltip && (
95119
<span
96120
className={cn(
97-
"pointer-events-none absolute bottom-full left-1/2 mb-2 -translate-x-1/2 rounded border border-grid-bright bg-background-bright px-1.5 py-0.5 text-xs tabular-nums text-text-bright shadow-md transition-opacity",
121+
"pointer-events-none absolute bottom-full left-1/2 mb-2.5 -translate-x-1/2 rounded border border-grid-bright bg-background-bright px-1.5 py-0.5 text-xs tabular-nums text-text-bright shadow-md transition-opacity",
98122
// Deliberately not keyed off focus: the thumb keeps focus after a
99123
// click, which would leave the label stuck on.
100124
isDragging ? "opacity-100" : "opacity-0 group-hover/thumb:opacity-100"
101125
)}
102126
>
103127
{valueTooltip(currentValue)}
128+
{/* Straddles the bottom edge, hiding the border it overlaps, so the
129+
two outer sides read as an arrow pointing at the handle. */}
130+
<span className="absolute left-1/2 top-full size-2 -translate-x-1/2 -translate-y-1/2 rotate-45 border-b border-l border-grid-bright bg-background-bright" />
104131
</span>
105132
)}
106133
</RadixSlider.Thumb>

apps/webapp/app/routes/account._index/route.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ import { pageMeta } from "~/utils/pageTitle";
4242

4343
export const meta = pageMeta("Your profile");
4444

45+
/** The contrast the slider ticks and labels as "Default". Note this is not the
46+
* same as `DEFAULT_THEME_CONTRAST`, the value applied when none is saved. */
47+
const DEFAULT_CONTRAST_MARK = 20;
48+
4549
function themeIcon(value: ThemePreference) {
4650
const Icon = THEME_OPTIONS_BY_VALUE[value].icon;
4751
return <Icon className="size-4 text-text-bright" />;
@@ -340,7 +344,10 @@ export default function Page() {
340344
min={0}
341345
max={100}
342346
step={1}
343-
valueTooltip={(value) => `${value}%`}
347+
marks={[DEFAULT_CONTRAST_MARK]}
348+
valueTooltip={(value) =>
349+
value === DEFAULT_CONTRAST_MARK ? "Default" : `${value}%`
350+
}
344351
value={[contrastPreview]}
345352
onValueChange={(values) => {
346353
// Live preview before the preference persists

0 commit comments

Comments
 (0)