Forms and inputsSlider
Built by DesignMe, free to use under the MIT licence.
Expert design and dev support for startups and enterprises, from Astro sites to React apps and AI products.
Book a callSlider
Named stops, a stepped scale and a range, with the agent's recommended value marked on the track. Free and MIT licensed, as HTML, a typed React component or an Astro component, in two styles with light and dark mode.
Install
Add it to your project with the CLI.
HTML, CSS and JavaScript
npx designme add sliderReact
npx designme add slider --reactAstro
npx designme add slider --astroThe CLI copies the source into your project, so you own it and can change anything. The HTML version lands in ./designme with the shared core and a demo.html. The React version lands in src/components/designme and needs React 19; it works in Next.js, Vite and Astro islands. The Astro version lands in src/components/designme too, with the same markup, CSS and script as the HTML version. See Astro.
shadcn CLI, React
npx shadcn@latest add https://designme.dev/r/shadcn/slider.json shadcn CLI, Astro
npx shadcn@latest add https://designme.dev/r/shadcn/astro/slider.json In a project set up for shadcn/ui, its CLI adds the same files to your components folder, under designme/, with the shared core. To give the components your shadcn or Tailwind v4 theme, add the theme bridge.
About
What it is for and what it does.
Sliders for the settings an agent takes: a single slider with named stops (a Creativity slider from Precise to Creative, with a sample sentence that rewrites itself in the chosen tone), a stepped slider with a tick and a number at each step, and a range with two knobs that never cross. A value the agent recommends sits on the track as a mark you can press, and a knob dropped near it snaps to it.
- For a setting with a few named levels that people understand better as words, like tone or effort.
- For a whole number that moves in fixed steps, like tool calls or retries per run.
- For a budget or a window with a lower and an upper bound.
- When the agent can suggest a good value and you want people to see it without being forced to take it.
- Drag a knob, or press anywhere on the track: the nearest knob comes to the pointer and follows it.
- The value shows over the knob while you drag it, hover it or move it with the keys, and in the header beside the label.
namessplits the range into named parts (Precise, Focused, Balanced, Playful, Creative); the nearest name shows beside the value.stopsadds buttons under the track that glide the knob to a value;ticksadds a tick and a number at each step, each one a button.recommendeddraws the agent's mark over the track and a notch in it. Pressing the mark glides the knob there, and a knob dragged within 5% of it snaps to it.descriptionis a line under the slider that crossfades when it changes, like a sample sentence in the chosen tone.RangeSliderkeeps its two knobs at least one step apart, so they can't cross; pressing the track moves the nearer knob.SliderGroupis the panel they sit in on designme.dev, with a hairline between sliders.
- Each knob has role="slider" with aria-valuemin, aria-valuemax and aria-valuenow, and an aria-valuetext with the unit and the name, like "0.40, Balanced, recommended" or "At most $0.35".
- On a range, each knob's limits stop at the other knob, so a screen reader announces the real range.
- The arrow keys move one step, Shift with an arrow or Page Up and Page Down move ten (or
bigStep), and Home and End go to the ends. - Stops, ticks and the recommended mark are buttons, so they can be reached with Tab and pressed with Enter or Space; the mark has a label and a tooltip on hover and keyboard focus.
- The keyboard focus ring is the quiet grey ring; a mouse or touch never shows one.
Usage
Import it in React and drive it from your own state, or place it in an Astro page.
React
import { Slider } from '@/components/designme/slider/Slider';React example
The demo above as a React component, 40 lines you can copy.
'use client';
// The animated preview from designme.dev, rebuilt from the React components: try the far end of Creativity, take the agent's
// recommendation, read fewer tool calls, then narrow the budget.
import { useRef, useState } from 'react';
import { RangeSlider, Slider, SliderGroup } from './Slider';
import { useStory } from '../lib/useScript';
const NAMES = ['Precise', 'Focused', 'Balanced', 'Playful', 'Creative'];
const SAMPLE = [
'The export failed because the token expired. Renew it in Settings.',
'The export failed: your access token expired. Renew it in Settings, then run it again.',
"Your export didn't finish because its access token had expired. Renew it in Settings and try again.",
'Your export tripped over an expired token. A quick renewal in Settings will get it moving.',
'The export ran out of road when its token expired mid-trip. Renew it in Settings and send it on its way.'
];
export default function SliderDemo() {
const ref = useRef<HTMLDivElement>(null);
const [creativity, setCreativity] = useState(0.5);
const [calls, setCalls] = useState(30);
const [cost, setCost] = useState<[number, number]>([0.1, 0.6]);
useStory(ref, async ({ wait, press }) => {
await wait(1500); press('.dm-sdx-stops button:last-child');
await wait(3500); press('.dm-sdx-rec');
await wait(2000); setCalls(20);
await wait(2000); setCost([0.1, 0.35]);
});
return (
<div ref={ref} style={{ width: '100%' }}>
<SliderGroup>
<Slider label="Creativity" min={0} max={1} step={0.05} bigStep={4} value={creativity} onValueChange={setCreativity} format={v => v.toFixed(2)}
names={NAMES} stops={[{ value: 0, label: 'Precise' }, { value: 0.5, label: 'Balanced' }, { value: 1, label: 'Creative' }]}
recommended={{ value: 0.4 }} description={SAMPLE[Math.round(creativity * 4)]} />
<Slider label="Tool calls per run" min={10} max={50} step={10} bigStep={2} value={calls} onValueChange={setCalls} format={v => v + ' calls'} ticks />
<RangeSlider label="Cost per run" min={0} max={1} step={0.05} bigStep={4} value={cost} onValueChange={setCost} format={v => '$' + v.toFixed(2)} />
</SliderGroup>
</div>
);
}
Astro
In a page in src/pages. It plays the demo above; change its slider.js to show your own data. Attributes like class go on its root element.
---
import Slider from '../components/designme/slider/Slider.astro';
---
<Slider />
<Slider data-style="soft" class="my-class" />Props
Everything Slider takes.
SliderProps
| Prop | Type | What it does |
|---|---|---|
labelrequired | string | Like "Creativity" |
min | number | |
max | number | |
step | number | |
bigStep | number | Steps that Shift with an arrow, Page Up and Page Down move by (10) |
value | number | |
defaultValue | number | |
onValueChange | (value: number) => void | |
format | (value: number) => string | How the value reads over the knob and in the header, like "0.40" or "30 calls" |
names | string[] | Names for ranges of the value, like Precise to Creative: the nearest one shows beside the value and is spoken with it |
stops | SliderStop[] | Buttons under the track that move the knob to a value |
ticks | boolean | number[] | A tick and a number under the track at each of these values (or at every step with `true`), each one a button |
recommended | { value: number; label?: string } | A value the agent recommends: a mark over the track you can press, and a knob dropped near it snaps to it |
description | string | A line under the slider that changes with it, like a sample sentence in the chosen tone |
className | string |
RangeSliderProps
| Prop | Type | What it does |
|---|---|---|
labelrequired | string | Like "Cost per run" |
min | number | |
max | number | |
step | number | |
bigStep | number | |
value | [number, number] | |
defaultValue | [number, number] | |
onValueChange | (value: [number, number]) => void | |
format | (value: number) => string | |
thumbLabels | [string, string] | What each knob is called for screen readers ("Lowest", "Highest") |
showEnds | boolean | Shows `min` and `max` under the ends of the track |
className | string |
Code
The full source, as the CLI installs it.
Slider.tsxReact · 165 lines
'use client';
import { useEffect, useId, useRef, useState, type ReactNode } from 'react';
import { cx } from '../lib/cx';
import { useControllable } from '../lib/hooks';
import { Icon } from '../lib/icons';
import { Swap, Tip } from '../lib/motion';
import '../designme.css';
import './slider.css';
interface TrackProps {
min: number; max: number; step: number; big: number; values: number[]; onChange: (v: number[]) => void;
format: (v: number, i: number) => string; valueText: (v: number, i: number) => string; labels: string[]; labelledBy?: string;
recommended?: { value: number; label?: string }; snap: number; gliding: boolean; setGliding: (on: boolean) => void;
}
const fix = (x: number) => +x.toFixed(4);
/* one track: knobs you drag or move with the keys, the value over the knob while it moves, the covered part in the accent */
function Track({ min, max, step, big, values, onChange, format, valueText, labels, labelledBy, recommended, snap, gliding, setGliding }: TrackProps) {
const ref = useRef<HTMLDivElement>(null);
const knobs = useRef<(HTMLSpanElement | null)[]>([]);
const [drag, setDrag] = useState(false);
const [active, setActive] = useState(0);
const [say, setSay] = useState(false);
const sayT = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(sayT.current), []);
const pct = (x: number) => (x - min) / (max - min) * 100;
const two = values.length > 1;
const clamp = (i: number, x: number) => { let lo = min, hi = max; if (two) { if (i === 0) hi = values[1] - step; else lo = values[0] + step; } return Math.max(lo, Math.min(hi, x)); };
const set = (i: number, x: number, near = false) => {
let v = clamp(i, Math.round((x - min) / step) * step + min);
if (near && recommended && Math.abs(v - recommended.value) <= snap) v = recommended.value;
if (fix(v) === fix(values[i])) return;
const next = values.slice(); next[i] = fix(v); onChange(next);
};
const flash = (i: number) => { setActive(i); setSay(true); clearTimeout(sayT.current); sayT.current = setTimeout(() => setSay(false), 1200); };
const at = (clientX: number) => { const b = ref.current!.getBoundingClientRect(); return min + (max - min) * Math.max(0, Math.min(1, (clientX - b.left) / b.width)); };
const down = (e: React.PointerEvent<HTMLDivElement>) => {
if ((e.target as Element).closest('.dm-sdx-rec') || e.button) return;
const x = at(e.clientX);
const i = two ? (Math.abs(x - values[0]) < Math.abs(x - values[1]) || x < values[0] ? 0 : 1) : 0;
e.preventDefault(); setGliding(false); setActive(i); setDrag(true); e.currentTarget.setPointerCapture(e.pointerId);
knobs.current[i]?.focus({ preventScroll: true }); set(i, x, true);
};
const key = (i: number) => (e: React.KeyboardEvent) => {
const bigStep = step * big;
const d = ({ ArrowRight: step, ArrowUp: step, ArrowLeft: -step, ArrowDown: -step, PageUp: bigStep, PageDown: -bigStep } as Record<string, number>)[e.key];
if (d != null) { e.preventDefault(); set(i, values[i] + (e.shiftKey && e.key.startsWith('Arrow') ? Math.sign(d) * bigStep : d)); flash(i); }
else if (e.key === 'Home' || e.key === 'End') { e.preventDefault(); set(i, e.key === 'Home' ? -Infinity : Infinity); flash(i); }
};
return (
<div ref={ref} className={cx('dm-sdx-tr', drag && 'dm-drag', gliding && 'dm-glide')} onPointerDown={down}
onPointerMove={e => { if (drag) set(active, at(e.clientX), true); }} onPointerUp={() => setDrag(false)} onPointerCancel={() => setDrag(false)}>
<span className="dm-sdx-rail">
<i className="dm-sdx-fill" style={{ left: two ? pct(values[0]) + '%' : 0, width: (two ? pct(values[1]) - pct(values[0]) : pct(values[0])) + '%' }} />
{recommended && <i className="dm-sdx-notch" style={{ left: pct(recommended.value) + '%' }} />}
</span>
{recommended && <Tip label={recommended.label ?? `Recommended by the agent: ${format(recommended.value, 0)}`}>
<button type="button" className="dm-sdx-rec" style={{ left: pct(recommended.value) + '%' }} aria-label="Use the recommended value"
onClick={() => { setGliding(true); set(0, recommended.value); flash(0); }}><Icon name="spark" size="xs" /></button>
</Tip>}
{values.map((v, i) => (
<span key={i} ref={el => { knobs.current[i] = el; }} className={cx('dm-sdx-k', i === active && 'dm-on', i === active && say && 'dm-say')} role="slider" tabIndex={0}
aria-label={labelledBy ? undefined : labels[i]} aria-labelledby={labelledBy} aria-valuemin={two && i ? fix(values[0] + step) : min} aria-valuemax={two && !i ? fix(values[1] - step) : max}
aria-valuenow={fix(v)} aria-valuetext={valueText(v, i)} style={{ left: pct(v) + '%' }} onKeyDown={key(i)}>
<span className="dm-sdx-tt dm-num">{format(v, i)}</span>
</span>
))}
</div>
);
}
const useGlide = () => {
const [on, setOn] = useState(false);
const t = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => () => clearTimeout(t.current), []);
return [on, (v: boolean) => { setOn(v); clearTimeout(t.current); if (v) t.current = setTimeout(() => setOn(false), 500); }] as const;
};
export interface SliderStop { value: number; label: string }
export interface SliderProps {
/** Like "Creativity" */
label: string;
min?: number;
max?: number;
step?: number;
/** Steps that Shift with an arrow, Page Up and Page Down move by (10) */
bigStep?: number;
value?: number;
defaultValue?: number;
onValueChange?: (value: number) => void;
/** How the value reads over the knob and in the header, like "0.40" or "30 calls" */
format?: (value: number) => string;
/** Names for ranges of the value, like Precise to Creative: the nearest one shows beside the value and is spoken with it */
names?: string[];
/** Buttons under the track that move the knob to a value */
stops?: SliderStop[];
/** A tick and a number under the track at each of these values (or at every step with `true`), each one a button */
ticks?: boolean | number[];
/** A value the agent recommends: a mark over the track you can press, and a knob dropped near it snaps to it */
recommended?: { value: number; label?: string };
/** A line under the slider that changes with it, like a sample sentence in the chosen tone */
description?: string;
className?: string;
}
/** A slider with named stops, ticks and an agent's recommended value. Arrows move a step, Shift or Page Up/Down ten, Home and End the ends. */
export function Slider({ label, min = 0, max = 100, step = 1, bigStep = 10, value, defaultValue = min, onValueChange, format = v => String(v), names, stops, ticks, recommended, description, className }: SliderProps) {
const [v, setV] = useControllable(value, defaultValue, onValueChange);
const [gliding, setGliding] = useGlide();
const id = useId();
const name = (x: number) => names ? names[Math.min(names.length - 1, Math.round((x - min) / (max - min) * (names.length - 1)))] : '';
const head = names ? `${name(v)}, ${format(v)}` : format(v);
const tickVals = ticks === true ? Array.from({ length: Math.round((max - min) / step) + 1 }, (_, i) => fix(min + i * step)) : ticks || [];
const go = (x: number) => { setGliding(true); setV(x); };
return (
<div data-dm="" className={cx('dm-sdx-b', className)}>
<div className="dm-sdx-h"><span id={id}>{label}</span><span className="dm-sdx-v dm-num">{head}</span></div>
<Track min={min} max={max} step={step} big={bigStep} values={[v]} onChange={n => setV(n[0])} format={format} labels={[label]} labelledBy={id}
valueText={x => [format(x), name(x), recommended && Math.abs(x - recommended.value) < 1e-3 ? 'recommended' : ''].filter(Boolean).join(', ')}
recommended={recommended} snap={(max - min) * .05} gliding={gliding} setGliding={setGliding} />
{stops && <div className="dm-sdx-stops">{stops.map(s => <button type="button" key={s.value} aria-pressed={Math.abs(s.value - v) < 1e-3} onClick={() => go(s.value)}>{s.label}</button>)}</div>}
{tickVals.length > 0 && <div className="dm-sdx-ticks dm-num">{tickVals.map(t => <button type="button" key={t} aria-pressed={Math.abs(t - v) < 1e-3} aria-label={`${format(t)}`} style={{ left: (t - min) / (max - min) * 100 + '%' }} onClick={() => go(t)}><i />{t}</button>)}</div>}
{description != null && <p className="dm-sdx-sm" aria-live="polite"><Swap>{description}</Swap></p>}
</div>
);
}
export interface RangeSliderProps {
/** Like "Cost per run" */
label: string;
min?: number;
max?: number;
step?: number;
bigStep?: number;
value?: [number, number];
defaultValue?: [number, number];
onValueChange?: (value: [number, number]) => void;
format?: (value: number) => string;
/** What each knob is called for screen readers ("Lowest", "Highest") */
thumbLabels?: [string, string];
/** Shows `min` and `max` under the ends of the track */
showEnds?: boolean;
className?: string;
}
/** Two knobs on one track for a range. They stay at least a step apart, so they never cross. */
export function RangeSlider({ label, min = 0, max = 100, step = 1, bigStep = 10, value, defaultValue = [min, max], onValueChange, format = v => String(v), thumbLabels = ['Lowest', 'Highest'], showEnds = true, className }: RangeSliderProps) {
const [v, setV] = useControllable(value, defaultValue, onValueChange);
const [gliding, setGliding] = useGlide();
return (
<div data-dm="" className={cx('dm-sdx-b', className)}>
<div className="dm-sdx-h"><span>{label}</span><span className="dm-sdx-v dm-num">{format(v[0])} to {format(v[1])}</span></div>
<Track min={min} max={max} step={step} big={bigStep} values={v} onChange={n => setV([n[0], n[1]])} format={format}
labels={thumbLabels.map(t => `${t} ${label.toLowerCase()}`)} valueText={(x, i) => (i ? 'At most ' : 'At least ') + format(x)} snap={0} gliding={gliding} setGliding={setGliding} />
{showEnds && <div className="dm-sdx-ends dm-muted dm-num"><span>{format(min)}</span><span>{format(max)}</span></div>}
</div>
);
}
/** The panel the sliders sit in on designme.dev, with a hairline between them */
export function SliderGroup({ children, className }: { children: ReactNode; className?: string }) {
return <div data-dm="" className={cx('dm-sdx dm-panel', className)}>{children}</div>;
}
slider.cssReact · 39 lines
/* Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync. */
/* ---------- Slider ---------- */
/* three sliders on one grey track style: a knob you drag or move with the keys, the value over it while it moves, and the
part of the track it covers in the accent */
.dm-sdx{padding:var(--dm-s-4);display:flex;flex-direction:column;gap:var(--dm-s-4)}
.dm-sdx-b{display:flex;flex-direction:column;gap:var(--dm-s-1)}
.dm-sdx-b+.dm-sdx-b{padding-top:var(--dm-s-4);border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.dm-sdx-h{display:flex;align-items:baseline;justify-content:space-between;gap:var(--dm-s-2);font-size:13px}
.dm-sdx-v{color:var(--dm-fg2);font-size:12.5px}
.dm-sdx-tr{position:relative;height:32px;touch-action:none;cursor:pointer;margin:0 3px}
.dm-sdx-rail{position:absolute;left:-3px;right:-3px;top:50%;height:6px;margin-top:-3px;border-radius:calc(3px * var(--dm-rk));background:var(--dm-chip);overflow:hidden}
.dm-sdx-fill{position:absolute;top:0;bottom:0;left:0;width:0;background:var(--dm-accent-mark,var(--dm-accent))}
.dm-sdx-k{position:absolute;top:50%;left:0;width:6px;height:22px;margin:-11px 0 0 -3px;border-radius:var(--dm-r-pill);background:var(--dm-fg);cursor:grab;z-index:2;transition:transform .2s var(--dm-ease)}
.dm-sdx-k:focus-visible{outline-offset:3px}
.dm-sdx-tr.dm-glide .dm-sdx-k{transition:transform .2s var(--dm-ease),left .45s var(--dm-ease)}
.dm-sdx-tr.dm-glide .dm-sdx-fill{transition:left .45s var(--dm-ease),width .45s var(--dm-ease)}
.dm-sdx-tr.dm-drag .dm-sdx-k.dm-on{cursor:grabbing;transform:scaleY(1.1)}
.dm-sdx-tt{position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);padding:3px var(--dm-s-2);border-radius:var(--dm-r-pill);background:var(--dm-ink);color:var(--dm-on-ink);font-size:11px;white-space:nowrap;opacity:0;transition:opacity .2s;pointer-events:none}
.dm-sdx-k.dm-on.dm-say .dm-sdx-tt,.dm-sdx-tr.dm-drag .dm-sdx-k.dm-on .dm-sdx-tt{opacity:1}
@media (hover:hover) and (pointer:fine){.dm-sdx-k:hover .dm-sdx-tt{opacity:1}}
/* the value the agent recommends: a mark over the track that you can press, and a notch in it; a knob dropped near it snaps to it */
.dm-sdx-rec{position:absolute;top:-9px;width:20px;height:20px;margin-left:-10px;display:grid;place-items:center;border-radius:calc(6px * var(--dm-rk));color:var(--dm-accent-fg,var(--dm-accent));z-index:1;transition:background var(--dm-t-fast)}
@media (hover:hover) and (pointer:fine){.dm-sdx-rec:hover{background:var(--dm-accent-bg)}}
.dm-sdx-rec .dm-i{width:12px;height:12px}
.dm-sdx-tr[data-s="cr"] .dm-sdx-rail{box-shadow:none}
.dm-sdx-notch{position:absolute;top:0;bottom:0;width:2px;margin-left:-1px;background:var(--dm-surface)}
.dm-sdx-stops{display:flex;justify-content:space-between;margin:0 -6px}
.dm-sdx-stops button{height:24px;padding:0 6px;border-radius:calc(6px * var(--dm-rk));font-size:12px;color:var(--dm-muted);transition:background var(--dm-t-fast),color var(--dm-t-fast)}
@media (hover:hover) and (pointer:fine){.dm-sdx-stops button:hover{background:var(--dm-row);color:var(--dm-fg)}}
.dm-sdx-stops button[aria-pressed="true"]{color:var(--dm-fg)}
.dm-sdx-sm{margin:var(--dm-s-1) 0 0;min-height:calc(2 * 1.5em);font-size:13px;line-height:1.5;color:var(--dm-fg2)}
.dm-sdx-ticks{position:relative;height:28px;margin:-6px 3px 0}
.dm-sdx-ticks button{position:absolute;top:0;display:flex;flex-direction:column;align-items:center;gap:3px;width:32px;margin-left:-16px;padding-top:2px;font-size:11.5px;color:var(--dm-muted);border-radius:calc(6px * var(--dm-rk));transition:color var(--dm-t-fast)}
.dm-sdx-ticks button i{width:1px;height:5px;background:var(--dm-faint)}
.dm-sdx-ticks button[aria-pressed="true"]{color:var(--dm-fg)}
@media (hover:hover) and (pointer:fine){.dm-sdx-ticks button:hover{color:var(--dm-fg)}}
.dm-sdx-ticks button[aria-pressed="true"] i{background:var(--dm-fg2)}
.dm-sdx-ends{display:flex;justify-content:space-between;font-size:11.5px;margin-top:-2px}
Slider.astroAstro · 41 lines
---
// Generated from designme.dev by scripts/sync.mjs. Edit the site, then sync.
// Slider: Named stops, a stepped scale and a range, with the agent's recommended value marked on the track.
// The markup, CSS and script are designme.dev's own, run by the shared core in ../core, which mounts the component when it scrolls
// into view and plays its demo. To show your own data, change slider.js. The markup is kept exactly as it is (is:raw).
import type { HTMLAttributes } from 'astro/types';
import '../core/designme.css';
import './slider.css';
export interface Props extends HTMLAttributes<'div'> {
/** "soft" draws it in the Soft style. Leave it out for Blueprint, or for the style set on <html> (data-style="soft") */
'data-style'?: 'soft';
}
const { class: className, ...rest } = Astro.props;
---
<div class:list={['card', className]} data-c="slider" {...rest}>
<div class="stage-in"><Fragment is:raw>
<div class="sdx panel">
<div class="sdx-b">
<div class="sdx-h"><span id="sdx-cr">Creativity</span><span class="sdx-v num"></span></div>
<div class="sdx-tr" data-s="cr"><span class="sdx-rail"><i class="sdx-fill"></i></span><button class="sdx-rec" aria-label="Use the recommended value" data-tip="Recommended by the agent: 0.4"><svg class="i xs"><use href="#i-spark"/></svg></button><span class="sdx-k" role="slider" tabindex="0" aria-labelledby="sdx-cr"><span class="sdx-tt num"></span></span></div>
<div class="sdx-stops"><button data-v="0">Precise</button><button data-v="0.5">Balanced</button><button data-v="1">Creative</button></div>
<p class="sdx-sm" aria-live="polite"><span></span></p>
</div>
<div class="sdx-b">
<div class="sdx-h"><span id="sdx-mx">Tool calls per run</span><span class="sdx-v num"></span></div>
<div class="sdx-tr" data-s="mx"><span class="sdx-rail"><i class="sdx-fill"></i></span><span class="sdx-k" role="slider" tabindex="0" aria-labelledby="sdx-mx"><span class="sdx-tt num"></span></span></div>
<div class="sdx-ticks num"></div>
</div>
<div class="sdx-b">
<div class="sdx-h"><span id="sdx-co">Cost per run</span><span class="sdx-v num"></span></div>
<div class="sdx-tr" data-s="co"><span class="sdx-rail"><i class="sdx-fill"></i></span><span class="sdx-k" role="slider" tabindex="0" aria-label="Lowest cost per run"><span class="sdx-tt num"></span></span><span class="sdx-k" role="slider" tabindex="0" aria-label="Highest cost per run"><span class="sdx-tt num"></span></span></div>
<div class="sdx-ends muted num"><span>$0.00</span><span>$1.00</span></div>
</div>
</div>
</Fragment></div>
</div>
<script>
import './slider.js';
</script>
slider.htmlHTML · 23 lines
<div class="card" data-c="slider">
<div class="stage-in">
<div class="sdx panel">
<div class="sdx-b">
<div class="sdx-h"><span id="sdx-cr">Creativity</span><span class="sdx-v num"></span></div>
<div class="sdx-tr" data-s="cr"><span class="sdx-rail"><i class="sdx-fill"></i></span><button class="sdx-rec" aria-label="Use the recommended value" data-tip="Recommended by the agent: 0.4"><svg class="i xs"><use href="#i-spark"/></svg></button><span class="sdx-k" role="slider" tabindex="0" aria-labelledby="sdx-cr"><span class="sdx-tt num"></span></span></div>
<div class="sdx-stops"><button data-v="0">Precise</button><button data-v="0.5">Balanced</button><button data-v="1">Creative</button></div>
<p class="sdx-sm" aria-live="polite"><span></span></p>
</div>
<div class="sdx-b">
<div class="sdx-h"><span id="sdx-mx">Tool calls per run</span><span class="sdx-v num"></span></div>
<div class="sdx-tr" data-s="mx"><span class="sdx-rail"><i class="sdx-fill"></i></span><span class="sdx-k" role="slider" tabindex="0" aria-labelledby="sdx-mx"><span class="sdx-tt num"></span></span></div>
<div class="sdx-ticks num"></div>
</div>
<div class="sdx-b">
<div class="sdx-h"><span id="sdx-co">Cost per run</span><span class="sdx-v num"></span></div>
<div class="sdx-tr" data-s="co"><span class="sdx-rail"><i class="sdx-fill"></i></span><span class="sdx-k" role="slider" tabindex="0" aria-label="Lowest cost per run"><span class="sdx-tt num"></span></span><span class="sdx-k" role="slider" tabindex="0" aria-label="Highest cost per run"><span class="sdx-tt num"></span></span></div>
<div class="sdx-ends muted num"><span>$0.00</span><span>$1.00</span></div>
</div>
</div>
</div>
</div>
slider.cssHTML · 38 lines
/* ---------- Slider ---------- */
/* three sliders on one grey track style: a knob you drag or move with the keys, the value over it while it moves, and the
part of the track it covers in the accent */
.sdx{padding:var(--dm-s-4);display:flex;flex-direction:column;gap:var(--dm-s-4)}
.sdx-b{display:flex;flex-direction:column;gap:var(--dm-s-1)}
.sdx-b+.sdx-b{padding-top:var(--dm-s-4);border-top:1px solid color-mix(in srgb,var(--dm-line) 50%,transparent)}
.sdx-h{display:flex;align-items:baseline;justify-content:space-between;gap:var(--dm-s-2);font-size:13px}
.sdx-v{color:var(--dm-fg2);font-size:12.5px}
.sdx-tr{position:relative;height:32px;touch-action:none;cursor:pointer;margin:0 3px}
.sdx-rail{position:absolute;left:-3px;right:-3px;top:50%;height:6px;margin-top:-3px;border-radius:calc(3px * var(--dm-rk));background:var(--dm-chip);overflow:hidden}
.sdx-fill{position:absolute;top:0;bottom:0;left:0;width:0;background:var(--dm-accent-mark,var(--dm-accent))}
.sdx-k{position:absolute;top:50%;left:0;width:6px;height:22px;margin:-11px 0 0 -3px;border-radius:var(--dm-r-pill);background:var(--dm-fg);cursor:grab;z-index:2;transition:transform .2s var(--dm-ease)}
.sdx-k:focus-visible{outline-offset:3px}
.sdx-tr.glide .sdx-k{transition:transform .2s var(--dm-ease),left .45s var(--dm-ease)}
.sdx-tr.glide .sdx-fill{transition:left .45s var(--dm-ease),width .45s var(--dm-ease)}
.sdx-tr.drag .sdx-k.on{cursor:grabbing;transform:scaleY(1.1)}
.sdx-tt{position:absolute;bottom:calc(100% + 6px);left:50%;transform:translateX(-50%);padding:3px var(--dm-s-2);border-radius:var(--dm-r-pill);background:var(--dm-ink);color:var(--dm-on-ink);font-size:11px;white-space:nowrap;opacity:0;transition:opacity .2s;pointer-events:none}
.sdx-k.on.say .sdx-tt,.sdx-tr.drag .sdx-k.on .sdx-tt{opacity:1}
@media (hover:hover) and (pointer:fine){.sdx-k:hover .sdx-tt{opacity:1}}
/* the value the agent recommends: a mark over the track that you can press, and a notch in it; a knob dropped near it snaps to it */
.sdx-rec{position:absolute;top:-9px;width:20px;height:20px;margin-left:-10px;display:grid;place-items:center;border-radius:calc(6px * var(--dm-rk));color:var(--dm-accent-fg,var(--dm-accent));z-index:1;transition:background var(--dm-t-fast)}
@media (hover:hover) and (pointer:fine){.sdx-rec:hover{background:var(--dm-accent-bg)}}
.sdx-rec .i{width:12px;height:12px}
.sdx-tr[data-s="cr"] .sdx-rail{box-shadow:none}
.sdx-notch{position:absolute;top:0;bottom:0;width:2px;margin-left:-1px;background:var(--dm-surface)}
.sdx-stops{display:flex;justify-content:space-between;margin:0 -6px}
.sdx-stops button{height:24px;padding:0 6px;border-radius:calc(6px * var(--dm-rk));font-size:12px;color:var(--dm-muted);transition:background var(--dm-t-fast),color var(--dm-t-fast)}
@media (hover:hover) and (pointer:fine){.sdx-stops button:hover{background:var(--dm-row);color:var(--dm-fg)}}
.sdx-stops button[aria-pressed="true"]{color:var(--dm-fg)}
.sdx-sm{margin:var(--dm-s-1) 0 0;min-height:calc(2 * 1.5em);font-size:13px;line-height:1.5;color:var(--dm-fg2)}
.sdx-ticks{position:relative;height:28px;margin:-6px 3px 0}
.sdx-ticks button{position:absolute;top:0;display:flex;flex-direction:column;align-items:center;gap:3px;width:32px;margin-left:-16px;padding-top:2px;font-size:11.5px;color:var(--dm-muted);border-radius:calc(6px * var(--dm-rk));transition:color var(--dm-t-fast)}
.sdx-ticks button i{width:1px;height:5px;background:var(--dm-faint)}
.sdx-ticks button[aria-pressed="true"]{color:var(--dm-fg)}
@media (hover:hover) and (pointer:fine){.sdx-ticks button:hover{color:var(--dm-fg)}}
.sdx-ticks button[aria-pressed="true"] i{background:var(--dm-fg2)}
.sdx-ends{display:flex;justify-content:space-between;font-size:11.5px;margin-top:-2px}
slider.jsHTML · 84 lines
DM.def('slider', (r, c) => {
r.innerHTML = r.innerHTML.replace('{spark}', I('spark','xs'));
const q = s => $(`.sdx-tr[data-s="${s}"]`, r);
/* one track: its knobs, the range they run over and how values read. Pointer: press anywhere on the track and the nearest
knob comes to it and follows. Keys on a knob: arrows one step, Shift with an arrow or Page Up/Down ten, Home and End the
ends. Two knobs never cross */
function track(tr, o){
const ks = $$('.sdx-k', tr), fill = $('.sdx-fill', tr), v = o.vals.slice();
const pct = x => (x - o.min) / (o.max - o.min) * 100, snap = x => Math.round((x - o.min) / o.step) * o.step + o.min, fix = x => +x.toFixed(4);
const clamp = (i, x) => { let lo = o.min, hi = o.max; if (ks.length > 1){ if (i === 0) hi = v[1] - o.step; else lo = v[0] + o.step; } return Math.max(lo, Math.min(hi, x)); };
const paint = () => {
ks.forEach((k, i) => { k.style.left = pct(v[i]) + '%'; $('.sdx-tt', k).textContent = o.fmt(v[i]); k.setAttribute('aria-valuemin', ks.length > 1 && i ? fix(v[0] + o.step) : o.min); k.setAttribute('aria-valuemax', ks.length > 1 && !i ? fix(v[1] - o.step) : o.max); k.setAttribute('aria-valuenow', fix(v[i])); k.setAttribute('aria-valuetext', o.text(v[i], i)); });
fill.style.left = ks.length > 1 ? pct(v[0]) + '%' : '0'; fill.style.width = (ks.length > 1 ? pct(v[1]) - pct(v[0]) : pct(v[0])) + '%';
o.change && o.change(v);
};
const set = (i, x, near) => { x = clamp(i, snap(x)); if (near != null && o.rec != null && Math.abs(x - o.rec) <= near) x = o.rec; if (fix(x) === fix(v[i])) return; v[i] = fix(x); paint(); };
// a value set from outside (a stop, the recommended mark, a tick) glides there: the knob and the fill ease across while
// the value lands at once
let gt = 0;
const glide = (i, x) => { say(i); tr.classList.add('glide'); clearTimeout(gt); gt = setTimeout(() => tr.classList.remove('glide'), 500); set(i, x); };
let sayT = 0; const say = i => { ks.forEach((k, j) => k.classList.toggle('on', j === i)); ks[i].classList.add('say'); clearTimeout(sayT); sayT = setTimeout(() => ks[i].classList.remove('say'), 1200); };
let act = 0;
const at = e => { const b = tr.getBoundingClientRect(); return o.min + (o.max - o.min) * Math.max(0, Math.min(1, (e.clientX - b.left) / b.width)); };
tr.addEventListener('pointerdown', e => {
if (e.target.closest('.sdx-rec') || e.button) return;
const x = at(e); act = ks.length > 1 ? (Math.abs(x - v[0]) <= Math.abs(x - v[1]) ? (x < v[0] ? 0 : Math.abs(x - v[0]) < Math.abs(x - v[1]) ? 0 : 1) : 1) : 0;
tr.classList.remove('glide'); tr.setPointerCapture(e.pointerId); tr.classList.add('drag'); ks.forEach((k, j) => k.classList.toggle('on', j === act));
e.preventDefault(); ks[act].focus({ preventScroll:true }); set(act, x, (o.max - o.min) * .05);
tr.onpointermove = ev => set(act, at(ev), (o.max - o.min) * .05);
});
tr.onpointerup = tr.onpointercancel = () => { tr.classList.remove('drag'); tr.onpointermove = null; };
ks.forEach((k, i) => k.addEventListener('keydown', e => {
const big = o.step * (o.big || 10), d = { ArrowRight:o.step, ArrowUp:o.step, ArrowLeft:-o.step, ArrowDown:-o.step, PageUp:big, PageDown:-big }[e.key];
if (d != null){ e.preventDefault(); set(i, v[i] + (e.shiftKey && /Arrow/.test(e.key) ? Math.sign(d) * big : d)); say(i); }
else if (e.key === 'Home' || e.key === 'End'){ e.preventDefault(); set(i, e.key === 'Home' ? -Infinity : Infinity); say(i); }
}));
paint();
return { glide, get:() => v.slice(), knobs:ks };
}
/* Creativity: a temperature from 0 to 1, read as five named stops, with a sample line that rewrites itself in the tone */
const NAMES = ['Precise', 'Focused', 'Balanced', 'Playful', 'Creative'];
const SAMPLE = [
'The export failed because the token expired. Renew it in Settings.',
'The export failed: your access token expired. Renew it in Settings, then run it again.',
"Your export didn't finish because its access token had expired. Renew it in Settings and try again.",
'Your export tripped over an expired token. A quick renewal in Settings will get it moving.',
'The export ran out of road when its token expired mid-trip. Renew it in Settings and send it on its way.'
];
const band = x => Math.min(4, Math.round(x * 4)), sm = $('.sdx-sm span', r), stops = $$('.sdx-stops button', r), REC = .4;
let shown = -1;
const cr = track(q('cr'), { min:0, max:1, step:.05, big:4, vals:[.5], rec:REC, fmt:x => x.toFixed(2), text:x => `${x.toFixed(2)}, ${NAMES[band(x)]}${Math.abs(x - REC) < .001 ? ', recommended' : ''}`,
change:([x]) => { $('.sdx-v', q('cr').parentElement).textContent = `${NAMES[band(x)]}, ${x.toFixed(2)}`; stops.forEach(b => b.setAttribute('aria-pressed', Math.abs(+b.dataset.v - x) < .001));
const b = band(x); if (b !== shown){ shown = b; swap(sm, SAMPLE[b]); } } });
const rec = $('.sdx-rec', r); rec.style.left = REC * 100 + '%';
$('.sdx-rail', q('cr')).insertAdjacentHTML('beforeend', `<i class="sdx-notch" style="left:${REC * 100}%"></i>`);
rec.onclick = () => cr.glide(0, REC);
stops.forEach(b => b.onclick = () => cr.glide(0, +b.dataset.v));
/* Tool calls per run: steps of 10, with a tick and a number at each step you can press */
const T = [10, 20, 30, 40, 50], ticks = $('.sdx-ticks', r);
ticks.innerHTML = T.map(t => `<button data-v="${t}" style="left:${(t - 10) / 40 * 100}%" aria-label="${t} tool calls"><i></i>${t}</button>`).join('');
const mx = track(q('mx'), { min:10, max:50, step:10, big:2, vals:[30], fmt:x => x + ' calls', text:x => x + ' tool calls',
change:([x]) => { $('.sdx-v', q('mx').parentElement).textContent = x + ' calls'; $$('button', ticks).forEach(b => b.setAttribute('aria-pressed', +b.dataset.v === x)); } });
$$('button', ticks).forEach(b => b.onclick = () => mx.glide(0, +b.dataset.v));
/* Cost per run: a range in dollars; its two knobs stay a step apart */
const $f = x => '$' + x.toFixed(2);
const co = track(q('co'), { min:0, max:1, step:.05, big:4, vals:[.1, .6], fmt:$f, text:(x, i) => (i ? 'At most ' : 'At least ') + $f(x),
change:([a, b]) => { $('.sdx-v', q('co').parentElement).textContent = `${$f(a)} to ${$f(b)}`; } });
// the preview's story: try the far end, take the agent's recommendation, read fewer tool calls, then narrow the budget
if (r === c.st) c.story(async s => {
await s.beat(3);
await s.tap(stops[2]); await s.beat(4);
await s.point(rec); await s.beat(3);
await s.tap(rec); await s.beat(4);
await s.tap($('button[data-v="20"]', ticks)); await s.beat(3);
await s.point(co.knobs[1], false); await s.beat(1);
co.glide(1, .35); await s.beat(3);
s.away(); await s.beat(2);
});
});
Questions
What people ask about it.
- How do I show the named stops without fixing the slider to them?
- Use
namesfor the words andstepfor how finely it moves. The knob can sit anywhere; the name shown is the nearest one. - Can I turn off the snapping to the recommended value?
- Snapping only happens when
recommendedis set. Leave it out and the knob moves freely bystep. - How do I format money or units?
- Pass
format, for examplev => '$' + v.toFixed(2). It is used over the knob, in the header and in the spoken value.
More in Forms and inputs
Buttons, fields, pickers and sign-in flows for any product.