Slider - headless

createSlider drives styled SvSlider plus a custom draggable bar (rect measured in the component), one number.

A live, editable Svelte 5 component example from the SvGrid gallery (Headless Editors). Read the SvGrid UI component docs for the full API.

About this example

The headless slider core of the SvGrid UI kit in Svelte 5. createSlider is the runes state machine behind SvSlider: it owns the value to position math, stepping, keyboard and drag state, and exposes trackProps and thumbProps. The core never touches the DOM; the renderer measures the track and passes the rect in. One core drives the styled SvSlider and a custom draggable bar, both bound to a single number.

Headless slider - the same headless-first split as the grid. createSlider is the runes state machine behind <SvSlider>: it owns the value<->position math, stepping, keyboard and drag state, and exposes **prop-getters** you spread onto YOUR OWN markup. The core never touches the DOM: the renderer measures the track and passes the rect IN. One core drives the styled <SvSlider> AND a custom bar, both bound to a single number.

Imports, features and API used

Imports: @svgrid/grid

Frequently asked questions

Why does the renderer pass the rect in?

Converting a pointer position to a value needs the track's size and offset, and measuring is a DOM concern. The renderer measures with getBoundingClientRect and hands the rect to the core, which stays pure and testable.

How is dragging handled?

thumbProps wires pointer down and the core tracks the drag through pointer move and up, converting positions to stepped values; the render just reads the value.

Does the custom bar get the keyboard?

Yes. thumbProps includes the slider role, aria-valuenow and the arrow, Home, End and Page key handling.

Related documentation

Related articles

  • SvGrid vs TanStack Table - A Deep Dive - A concrete architectural comparison of SvGrid and TanStack Table's Svelte adapter - how each handles reactivity, rendering, and feature composition, with code that shows exactly where they diverge.
  • SvGrid vs svelte-headless-table - A practical comparison of SvGrid and svelte-headless-table covering reactivity model, rendering approach, feature scope, and when each is the right choice for a Svelte 5 project.
  • What Is a Headless Data Grid? - Headless grids separate behavior from rendering - sorting, filtering, grouping, and pagination logic with no markup attached. Here is what that means in practice, why it matters, and when the tradeoff is worth it.

Source code (281-headless-slider.svelte)

<script lang="ts">
  /**
   * Headless slider - the same headless-first split as the grid. `createSlider` is
   * the runes state machine behind <SvSlider>: it owns the value<->position math,
   * stepping, keyboard and drag state, and exposes **prop-getters** you spread onto
   * YOUR OWN markup. The core never touches the DOM: the renderer measures the track
   * and passes the rect IN. One core drives the styled <SvSlider> AND a custom bar,
   * both bound to a single number.
   */
  import { SvSlider, createSlider } from '@svgrid/grid'

  let value = $state(40)

  // The headless core - identical behavior, our own DOM.
  const s = createSlider({
    value: () => value,
    onChange: (v) => (value = v as number),
    min: () => 0,
    max: () => 100,
    step: () => 1,
    ariaLabel: () => 'Volume',
  })

  // The rect is measured here (a DOM concern) and passed INTO the core.
  let barEl: HTMLDivElement | null = null
  function down(e: PointerEvent) {
    s.pointerDown({ x: e.clientX, y: e.clientY }, barEl!.getBoundingClientRect())
    barEl?.setPointerCapture(e.pointerId)
  }
  function move(e: PointerEvent) {
    if (!s.dragging) return
    s.setFromPointer({ x: e.clientX, y: e.clientY }, barEl!.getBoundingClientRect())
  }
  function up(e: PointerEvent) { if (s.dragging) { s.endDrag(); barEl?.releasePointerCapture(e.pointerId) } }
</script>

<div class="wrap">
  <header>
    <h2>Headless slider</h2>
    <p>
      Every editor ships a headless core + a styled component - the same split as the grid
      (<code>createSvGrid</code> / <code>&lt;SvGrid&gt;</code>). Here <code>createSlider</code>
      drives both renders; the value math, keyboard and drag state come from the core, the markup is yours.
    </p>
  </header>

  <div class="cols">
    <section>
      <h3>Styled <code>&lt;SvSlider&gt;</code></h3>
      <SvSlider {value} min={0} max={100} showValue ariaLabel="Volume" onChange={(v) => (value = v)} />
    </section>

    <section>
      <h3>Your markup, same core</h3>
      <!-- svelte-ignore a11y_no_static_element_interactions -->
      <div class="bar" bind:this={barEl} {...s.trackProps()} onpointerdown={down} onpointermove={move} onpointerup={up} onpointercancel={up}>
        <div class="bar__fill" style:width={`${s.percentOf(s.hi)}%`}></div>
        <div class="bar__thumb" style:left={`${s.percentOf(s.hi)}%`} {...s.thumbProps(0)}></div>
      </div>
      <p class="hint">Click or drag the bar, or focus the knob and use arrows / Home / End - all from <code>createSlider</code>.</p>
    </section>
  </div>

  <aside class="readout">
    <h3>Value</h3>
    <div class="tags"><span class="tag">{value}</span></div>
  </aside>
</div>

<style>
  .wrap { padding: 20px; max-width: 900px; display: flex; flex-direction: column; gap: 18px; }
  header h2 { margin: 0 0 4px; font-size: 20px; font-weight: 700; }
  header p { margin: 0; color: var(--sg-muted, #64748b); font-size: 13.5px; line-height: 1.55; max-width: 680px; }
  code { background: var(--sg-row-hover-bg, #eef2ff); padding: 1px 5px; border-radius: 5px; font-size: 12px; }
  .cols { display: flex; gap: 32px; flex-wrap: wrap; align-items: flex-start; }
  section h3 { margin: 0 0 10px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.03em; color: var(--sg-muted, #64748b); }
  /* Fully custom slider render - nothing shared with SvSlider but the core. */
  .bar { position: relative; width: 260px; height: 14px; border-radius: 999px; background: var(--sg-border, #e2e8f0); cursor: pointer; touch-action: none; margin-top: 8px; }
  .bar__fill { position: absolute; inset: 0 auto 0 0; border-radius: 999px; background: var(--sg-accent, #4f46e5); }
  .bar__thumb { position: absolute; top: 50%; width: 22px; height: 22px; border-radius: 50%; background: var(--sg-input-bg, #fff); border: 3px solid var(--sg-accent, #4f46e5); transform: translate(-50%, -50%); box-shadow: 0 1px 4px rgba(0,0,0,0.25); touch-action: none; }
  .bar__thumb:focus-visible { outline: 2px solid var(--sg-accent, #4f46e5); outline-offset: 2px; }
  .hint { margin: 12px 0 0; font-size: 12px; color: var(--sg-muted, #94a3b8); line-height: 1.5; }
  .readout h3 { margin: 0 0 8px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.03em; color: var(--sg-muted, #64748b); }
  .tags { display: flex; gap: 6px; flex-wrap: wrap; }
  .tag { padding: 3px 10px; border-radius: 6px; font-size: 13px; background: var(--sg-row-hover-bg, #f1f5f9); font-variant-numeric: tabular-nums; }
</style>

View this example on GitHub

More Headless Editors examples

  • Headless editors - Headless-first, like the grid: createListbox is the state machine behind SvListBox (roving focus, single/multi selection, keyboard, ARIA) exposed as prop-getters you spread onto YOUR own markup. One core drives both the styled SvListBox and a custom chip-cloud render, bound to one value.
  • Combobox - headless - createCombobox drives the styled SvComboBox and a custom filter-input render, both bound to one value with a readout.
  • Dropdown list - headless - createDropdownList drives SvDropDownList and a custom trigger+menu render, sharing one value.
  • Autocomplete - headless - createAutocomplete drives SvAutoComplete and a custom suggestion-list render over one text value.
  • Tags input - headless - createTagsInput drives SvTagsInput and a custom chip-cloud render, both editing one string array.