Skip to content
Star on GitHub
Forms and inputsSlider
Style
Accent
Sound

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 call

Slider

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.

$npx designme add slider
Creativity

Tool calls per run
Cost per run
$0.00$1.00
01

Install

Add it to your project with the CLI.

HTML, CSS and JavaScript

$npx designme add slider

React

$npx designme add slider --react

Astro

$npx designme add slider --astro

The 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.

02

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.
03

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" />
04

Props

Everything Slider takes.

SliderProps

PropTypeWhat it does
labelrequiredstringLike "Creativity"
minnumber
maxnumber
stepnumber
bigStepnumberSteps that Shift with an arrow, Page Up and Page Down move by (10)
valuenumber
defaultValuenumber
onValueChange(value: number) => void
format(value: number) => stringHow the value reads over the knob and in the header, like "0.40" or "30 calls"
namesstring[]Names for ranges of the value, like Precise to Creative: the nearest one shows beside the value and is spoken with it
stopsSliderStop[]Buttons under the track that move the knob to a value
ticksboolean | 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
descriptionstringA line under the slider that changes with it, like a sample sentence in the chosen tone
classNamestring

RangeSliderProps

PropTypeWhat it does
labelrequiredstringLike "Cost per run"
minnumber
maxnumber
stepnumber
bigStepnumber
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")
showEndsbooleanShows `min` and `max` under the ends of the track
classNamestring
05

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);
  });
});
06

Questions

What people ask about it.

How do I show the named stops without fixing the slider to them?
Use names for the words and step for 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 recommended is set. Leave it out and the knob moves freely by step.
How do I format money or units?
Pass format, for example v => '$' + v.toFixed(2). It is used over the knob, in the header and in the spoken value.
07

More in Forms and inputs

Buttons, fields, pickers and sign-in flows for any product.

ButtonsEvery button style, with loading, success and hold-to-confirm states.
BadgeSmall labels for plans, environments and status, with a dot, an icon or a remove button.
Progress and meterA run's progress as a ring, steps and a bar, beside a credits meter that warns as it fills.
TooltipQuiet labels on hover, with shortcuts and a richer preview card.
Search inputSearch with scoped filters, recent queries and an agent answer.
Country selectA searchable select with country codes, currency and keyboard support.
Multi-selectA combobox that filters as you type and keeps your picks as chips.
Colour pickerPick a colour by eye or by hex, check its contrast, and let the agent fix it or make a scale from it.
All 15 in Forms and inputs →