component

Input Range

A styled wrapper around <input type="range"> that shares the feedback, size, and background vocabulary of the rest of the form surface. The default fader look is a full control-height row: a rounded fill grows to a thin handle bar, with the label and a live value readout inside the row. It follows the same control sizes as Input and Button, so it lines up with them in forms.

Fader

In a form, label the fader with a Field like any other control. The row is split into zones: the track (fill, handle and step dots) runs between the text segments, so the handle and dots never pass under the value. The readout reserves room for its widest value, so the track doesn’t shift as the number changes. Combine with appearance="stepped" and marks for dotted, snapping stops. When the value lands on a labelled mark, the readout shows the mark’s label instead of the number (“Medium” below). In dual mode, the start value reads on the left and the end value on the right.

Stepped: dots per step, snaps to them.

LowMediumHigh
---
// GENERATED from packages/demos/input-range/fader.demo.tsx — edit the source, not this file.
import InputRange from "@pindoba/astro-input-range";
import Field from "@pindoba/astro-field";
import { stack } from "@pindoba/styled-system/patterns";

const percent = { style: "percent", locale: "en-US" } as const;
const usd = {
  style: "currency",
  currency: "USD",
  maximumFractionDigits: 0,
  locale: "en-US",
} as const;
const qualities = [
  { value: 0, label: "Low" },
  { value: 1, label: "Medium" },
  { value: 2, label: "High" },
];
---

<div
  class={stack({
    gap: "md",
    direction: "column",
    width: "32rem",
    maxWidth: "100%",
  })}
>
  <Field label="Opacity">
    <InputRange
      value={0.4}
      min={0}
      max={1}
      step={0.01}
      formatValue={percent}
      fullWidth
    />
  </Field>
  <Field label="Columns" description="Stepped: dots per step, snaps to them.">
    <InputRange
      value={3}
      min={1}
      max={6}
      step={1}
      appearance="stepped"
      marks
      fullWidth
    />
  </Field>
  <Field label="Quality">
    <InputRange
      value={1}
      min={0}
      max={2}
      step={1}
      appearance="stepped"
      marks={qualities}
      fullWidth
    />
  </Field>
  <Field label="Price">
    <InputRange
      valueStart={200}
      valueEnd={750}
      min={0}
      max={1000}
      step={10}
      formatValue={usd}
      fullWidth
    />
  </Field>
</div>

Label inside the row

For compact settings panels, pass label to put the label in the row’s leading segment. It also becomes the input’s accessible name. Use origin="center" for signed ranges.

Inline segments hug their text, so the tracks of a stack of rows start at different points. To line them up, use labelPosition below.

Label and value above or below

labelPosition="top" (or "bottom") turns the label and the live value into a header line, with the full-width track below (or above) it. Every track in a stack then starts and ends at the same place, which suits settings and inspector panels. Dual ranges work the same way: the start value on the left and the end value on the right, or the label on the left and start – end on the right.

---
// GENERATED from packages/demos/input-range/label-position.demo.tsx — edit the source, not this file.
import InputRange from "@pindoba/astro-input-range";
import { stack } from "@pindoba/styled-system/patterns";

const usd = {
  style: "currency",
  currency: "USD",
  maximumFractionDigits: 0,
  locale: "en-US",
} as const;
const grouped = { locale: "en-US" } as const;
---

<div
  class={stack({
    gap: "md",
    direction: "column",
    width: "32rem",
    maxWidth: "100%",
  })}
>
  <InputRange
    label="Rotation speed"
    labelPosition="top"
    value={3.1}
    min={0}
    max={5}
    step={0.1}
    fullWidth
  />
  <InputRange
    label="Spiral arms"
    labelPosition="top"
    value={4}
    min={1}
    max={6}
    step={1}
    appearance="stepped"
    marks
    fullWidth
  />
  <InputRange
    label="Stars"
    labelPosition="top"
    value={4000}
    min={0}
    max={10000}
    step={100}
    formatValue={grouped}
    fullWidth
  />
  <InputRange
    labelPosition="top"
    valueStart={200}
    valueEnd={750}
    min={0}
    max={1000}
    step={10}
    formatValue={usd}
    fullWidth
  />
  <InputRange
    label="Hours"
    labelPosition="bottom"
    valueStart={9}
    valueEnd={17}
    min={0}
    max={24}
    minGap={1}
    fullWidth
  />
</div>
LowMediumHigh
---
// GENERATED from packages/demos/input-range/inline-label.demo.tsx — edit the source, not this file.
import InputRange from "@pindoba/astro-input-range";
import { stack } from "@pindoba/styled-system/patterns";

const qualities = [
  { value: 0, label: "Low" },
  { value: 1, label: "Medium" },
  { value: 2, label: "High" },
];
---

<div
  class={stack({
    gap: "xs",
    direction: "column",
    width: "32rem",
    maxWidth: "100%",
  })}
>
  <InputRange label="Volume" value={62} min={0} max={100} fullWidth />
  <InputRange
    label="Speed"
    value={1}
    min={0.5}
    max={2}
    step={0.25}
    appearance="stepped"
    marks
    fullWidth
  />
  <InputRange
    label="Balance"
    value={0}
    min={-50}
    max={50}
    step={10}
    origin="center"
    appearance="stepped"
    marks
    fullWidth
  />
  <InputRange
    label="Quality"
    value={1}
    min={0}
    max={2}
    step={1}
    appearance="stepped"
    marks={qualities}
    fullWidth
  />
</div>

Formatting the readout

formatValue formats the readout and the input’s aria-valuetext. Pass a function ((n) => n + "%") or Intl.NumberFormat options, optionally with a locale. In Astro, prefer the options form: it is serializable, so the client script keeps formatting as the value changes. A function only formats the server render there.

Track variant

variant="track" keeps the classic thin rail with a round thumb, a primary tint and a transparent row. Prefix/suffix columns and tick labels are track-only layouts. Most demos below use it.

Feedback

Apply semantic feedback colors to tint the filled track and thumb — useful for tying a slider to a validation state or a themed control group.

---
// GENERATED from packages/demos/input-range/feedback.demo.tsx — edit the source, not this file.
import InputRange from "@pindoba/astro-input-range";
import { stack } from "@pindoba/styled-system/patterns";
---

<div class={stack({ gap: "md", direction: "column" })}>
  <InputRange
    label="Neutral"
    feedback="neutral"
    value={40}
    min={0}
    max={100}
    fullWidth
  />
  <InputRange
    label="Primary"
    feedback="primary"
    value={55}
    min={0}
    max={100}
    fullWidth
  />
  <InputRange
    label="Success"
    feedback="success"
    value={70}
    min={0}
    max={100}
    fullWidth
  />
  <InputRange
    label="Danger"
    feedback="danger"
    value={25}
    min={0}
    max={100}
    fullWidth
  />
  <InputRange
    label="Warning"
    feedback="warning"
    value={85}
    min={0}
    max={100}
    fullWidth
  />
</div>

Size

Four sizes follow the control size scale: the fader row matches the height, padding, and radius of an Input or Button of the same size. In the track variant, they scale the rail height and thumb diameter together.

---
// GENERATED from packages/demos/input-range/size.demo.tsx — edit the source, not this file.
import InputRange from "@pindoba/astro-input-range";
import { stack } from "@pindoba/styled-system/patterns";
---

<div class={stack({ gap: "md", direction: "column" })}>
  <InputRange
    label="Extra small"
    size="xs"
    value={20}
    min={0}
    max={100}
    fullWidth
  />
  <InputRange label="Small" size="sm" value={40} min={0} max={100} fullWidth />
  <InputRange label="Medium" size="md" value={60} min={0} max={100} fullWidth />
  <InputRange label="Large" size="lg" value={80} min={0} max={100} fullWidth />
</div>

Prefix & Suffix

Use fieldPrefix and fieldSuffix to add labels or a live value readout alongside the track. The native <input> still owns the value — bind it to display the current number. Use labelPosition to place the labels inline with the track (default), or stacked on top or bottom.

Volume
35%
Brightness
72%
0
100
---
// GENERATED from packages/demos/input-range/prefix-suffix.demo.tsx — edit the source, not this file.
import InputRange from "@pindoba/astro-input-range";
import { stack } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";

const volume = 35;
const brightness = 72;
---

<div
  class={stack({ gap: "lg", direction: "column" })}
  data-ir-root="input-range/prefix-suffix"
>
  <InputRange
    variant="track"
    aria-label="Volume"
    value={35}
    min={0}
    max={100}
    fullWidth
    data-ir="1"
  >
    <span slot="field-prefix" class={css({ whiteSpace: "nowrap" })}>
      Volume
    </span>
    <span
      slot="field-suffix"
      class={css({ minWidth: "3ch", textAlign: "right" })}
    >
      <!--ir:2-->
      {volume}
      <!--/ir-->%
    </span>
  </InputRange>

  <InputRange
    variant="track"
    aria-label="Brightness"
    value={72}
    min={0}
    max={100}
    step={5}
    fullWidth
    labelPosition="top"
    data-ir="3"
  >
    <span slot="field-prefix">Brightness</span>
    <span slot="field-suffix" class={css({ textAlign: "right" })}>
      <!--ir:4-->
      {brightness}
      <!--/ir-->%
    </span>
  </InputRange>

  <InputRange
    variant="track"
    aria-label="Value"
    value={60}
    min={0}
    max={100}
    fullWidth
    labelPosition="bottom"
    data-ir="5"
  >
    <span slot="field-prefix">0</span>
    <span slot="field-suffix" class={css({ textAlign: "right" })}>
      100
    </span>
  </InputRange>
</div>

<script>
  import { hydrate } from "./_client/prefix-suffix.ts";

  document
    .querySelectorAll('[data-ir-root="input-range/prefix-suffix"]')
    .forEach((root) => hydrate([root]));
</script>

Dual Range

Provide valueStart and valueEnd (instead of a single value) to select an interval with two thumbs — ideal for price ranges or bounds. In the fader, the start value reads on the left and the end value on the right. With a label, the label takes the left side and the interval reads as start – end on the right. Set minGap to keep a minimum distance between the thumbs. Each thumb is a real native <input type="range">, so keyboard and assistive-tech support come for free.

---
// GENERATED from packages/demos/input-range/dual.demo.tsx — edit the source, not this file.
import InputRange from "@pindoba/astro-input-range";
import { stack } from "@pindoba/styled-system/patterns";

const usd = {
  style: "currency",
  currency: "USD",
  maximumFractionDigits: 0,
  locale: "en-US",
} as const;
---

<div
  class={stack({
    gap: "md",
    direction: "column",
    width: "28rem",
    maxWidth: "100%",
  })}
>
  <InputRange
    valueStart={200}
    valueEnd={750}
    min={0}
    max={1000}
    step={10}
    formatValue={usd}
    fullWidth
  />

  <InputRange
    label="Hours"
    valueStart={9}
    valueEnd={17}
    min={0}
    max={24}
    minGap={1}
    fullWidth
  />

  <InputRange
    variant="track"
    valueStart={30}
    valueEnd={60}
    min={0}
    max={100}
    minGap={10}
    feedback="success"
    showValue="always"
    fullWidth
  />
</div>

Tick Marks

Pass marks to render dots along the track: marks (with a step) auto-generates evenly-spaced marks, or pass an array of { value, label? } to place explicit, labelled marks.

FasterSmarter
---
// GENERATED from packages/demos/input-range/marks.demo.tsx — edit the source, not this file.
import InputRange from "@pindoba/astro-input-range";
import { stack } from "@pindoba/styled-system/patterns";
---

<div
  class={stack({
    gap: "lg",
    direction: "column",
    width: "28rem",
    maxWidth: "100%",
  })}
>
  <InputRange
    variant="track"
    aria-label="Value"
    value={40}
    min={0}
    max={100}
    step={20}
    marks
    fullWidth
  />

  <InputRange
    variant="track"
    aria-label="Speed"
    value={50}
    min={0}
    max={100}
    step={25}
    marks={[
      { value: 0, label: "Faster" },
      { value: 100, label: "Smarter" },
    ]}
    showValue
    fullWidth
  />
</div>

Stepped

appearance="stepped" gives the segmented look — a wider pill thumb, firmer dots, and snap-to-mark defaulted on. Combine with marks and a step.

Faster
Smarter
---
// GENERATED from packages/demos/input-range/stepped.demo.tsx — edit the source, not this file.
import InputRange from "@pindoba/astro-input-range";
import { stack } from "@pindoba/styled-system/patterns";
---

<div
  class={stack({
    gap: "lg",
    direction: "column",
    width: "28rem",
    maxWidth: "100%",
  })}
>
  <InputRange
    variant="track"
    aria-label="Speed"
    value={50}
    min={0}
    max={100}
    step={25}
    appearance="stepped"
    marks
    fullWidth
  >
    <span slot="field-prefix">Faster</span>
    <span slot="field-suffix">Smarter</span>
  </InputRange>
</div>

Snap to Marks

Set snap with an array of marks (and step="any" for fine input resolution) to snap the value to the nearest mark on drag or arrow-key — including unevenly-spaced stops.

Level: 25
0102560100
---
// GENERATED from packages/demos/input-range/snap.demo.tsx — edit the source, not this file.
import InputRange from "@pindoba/astro-input-range";
import { stack } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";

const stops = [0, 10, 25, 60, 100];

const level = 25;
---

<div
  class={stack({
    gap: "sm",
    direction: "column",
    width: "28rem",
    maxWidth: "100%",
  })}
  data-ir-root="input-range/snap"
>
  <span class={css({ fontSize: "sm", color: "neutral.text.muted" })}>
    Level: <!--ir:1-->
    {level}
    <!--/ir-->
  </span>
  <InputRange
    variant="track"
    aria-label="Value"
    value={25}
    min={0}
    max={100}
    step="any"
    snap
    marks={stops.map((value) => ({ value, label: String(value) }))}
    fullWidth
    data-ir="2"
  />
</div>

<script>
  import { hydrate } from "./_client/snap.ts";

  document
    .querySelectorAll('[data-ir-root="input-range/snap"]')
    .forEach((root) => hydrate([root]));
</script>

Custom Styling

The passThrough prop provides escape hatches for each slot: style accepts a Panda CSS SystemStyleObject and props forwards arbitrary HTML attributes to the corresponding element.

Wrapped in a surface via passThrough

Squared thumb via passThrough

Forwarded ARIA attributes

Opacity
50%
---
// GENERATED from packages/demos/input-range/custom.demo.tsx — edit the source, not this file.
import InputRange from "@pindoba/astro-input-range";
import { stack } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";

const opacity = 50;
---

<div
  class={stack({ gap: "xl", direction: "column" })}
  data-ir-root="input-range/custom"
>
  <div>
    <h3>Wrapped in a surface via passThrough</h3>
    <InputRange
      variant="track"
      aria-label="Custom surface"
      value={40}
      min={0}
      max={100}
      fullWidth
      passThrough={{
        root: {
          style: {
            background: "neutral.surface.hill",
            borderRadius: "md",
            padding: "md",
          },
        },
      }}
      data-ir="1"
    />
  </div>

  <div>
    <h3>Squared thumb via passThrough</h3>
    <InputRange
      variant="track"
      aria-label="Squared thumb"
      value={65}
      min={0}
      max={100}
      fullWidth
      passThrough={{
        input: {
          style: {
            "&::-webkit-slider-thumb": { borderRadius: "xs" },
            "&::-moz-range-thumb": { borderRadius: "xs" },
          },
        },
      }}
      data-ir="2"
    />
  </div>

  <div>
    <h3>Forwarded ARIA attributes</h3>
    <InputRange
      variant="track"
      value={50}
      min={0}
      max={100}
      fullWidth
      labelPosition="top"
      passThrough={{
        input: {
          props: {
            "aria-label": "Opacity",
            "data-testid": "opacity-slider",
          },
        },
      }}
      data-ir="3"
    >
      <span slot="field-prefix" class={css({ fontWeight: "medium" })}>
        Opacity
      </span>
      <span slot="field-suffix" class={css({ textAlign: "right" })}>
        <!--ir:4-->
        {opacity}
        <!--/ir-->%
      </span>
    </InputRange>
  </div>
</div>

<script>
  import { hydrate } from "./_client/custom.ts";

  document
    .querySelectorAll('[data-ir-root="input-range/custom"]')
    .forEach((root) => hydrate([root]));
</script>
  • Components
  • Blocks