Skip to content
touchcn
Esc
navigateopen⌘Jpreview
On this page

Slider

A range input for selecting a value from a continuum.

A single-value slider built on a real <input type="range">, so native touch, pointer and keyboard handling come for free. Android renders the MD3 16px thumb on a 4px track; iOS renders the 28px white thumb with a shadow on a thin track. The active fill tracks the value automatically.

Installation

npx touchcn add slider

Usage

<tcn-slider [(value)]="volume" [min]="0" [max]="100" [step]="1" />
import { TcnSlider } from '@/components/ui/slider';

<TcnSlider value={volume} onValueChange={setVolume} min={0} max={100} step={1} />
<script setup lang="ts">
import { TcnSlider } from '@/components/ui/slider';
</script>

<template>
  <TcnSlider v-model="volume" :min="0" :max="100" :step="1" />
</template>

Because it is a native range input, screen readers announce it as a slider with its current, min and max values out of the box.

Props

Prop Type Default Description
value number 0 Current value.
min number 0 Minimum value.
max number 100 Maximum value.
step number 1 Step increment.
onValueChange (value: number) => void Change callback (React).
disabled boolean false Disable interaction.
Source · Angular
export * from './tcn-slider';
import { booleanAttribute, Component, computed, input, model, numberAttribute } from '@angular/core';

/**
 * Single-value slider built on a real `<input type="range">` — native touch,
 * pointer and keyboard handling plus a built-in `role="slider"` come for free,
 * so no custom-drag plumbing is needed. The active fill is value-driven through
 * the `--tcn-slider-fill` custom property; `theme.css` styles the track and
 * thumb per platform via the webkit/moz range pseudo-elements (MD3 16px thumb,
 * iOS 28px white thumb with shadow).
 *
 * MD3 extras (theme-dumb, CSS-only): a value-label bubble that appears above the
 * thumb while dragging (revealed by `:has(.tcn-slider:active)` in the chrome, so
 * no drag state is tracked here) and tick marks for discrete (stepped) sliders.
 */
@Component({
  selector: 'tcn-slider',
  template: `
    <input
      type="range"
      class="tcn-slider"
      [min]="min()"
      [max]="max()"
      [step]="step()"
      [value]="value()"
      [disabled]="disabled()"
      [style.--tcn-slider-fill]="fill()"
      (input)="onInput($event)"
    />
    @if (showTicks()) {
      <div class="tcn-slider-ticks" aria-hidden="true" [style.--tcn-slider-step]="stepPercent()"></div>
    }
    <div class="tcn-slider-label" aria-hidden="true" [style.--tcn-slider-fill]="fill()">{{ value() }}</div>
  `,
  host: { class: 'tcn-slider-host relative block' },
})
export class TcnSlider {
  readonly min = input(0, { transform: numberAttribute });
  readonly max = input(100, { transform: numberAttribute });
  readonly step = input(1, { transform: numberAttribute });
  readonly value = model(0);
  readonly disabled = input(false, { transform: booleanAttribute });

  private readonly steps = computed(() => {
    const range = this.max() - this.min();
    const step = this.step();
    return step > 0 && range > 0 ? range / step : 0;
  });

  /** Ticks only for a discrete slider with a sensible number of steps. */
  protected readonly showTicks = computed(() => this.steps() >= 2 && this.steps() <= 30);
  protected readonly stepPercent = computed(() => `${100 / this.steps()}%`);

  protected readonly fill = computed(() => {
    const range = this.max() - this.min();
    const percent = range > 0 ? ((this.value() - this.min()) / range) * 100 : 0;
    return `${Math.max(0, Math.min(100, percent))}%`;
  });

  protected onInput(event: Event): void {
    this.value.set((event.target as HTMLInputElement).valueAsNumber);
  }
}
Source · React
export * from './tcn-slider';
import { forwardRef } from 'react';
import type { ComponentPropsWithoutRef, CSSProperties } from 'react';
import { cn } from '@touchcn/core';

export interface TcnSliderProps
  extends Omit<ComponentPropsWithoutRef<'input'>, 'type' | 'value' | 'min' | 'max' | 'step' | 'onChange'> {
  min?: number;
  max?: number;
  step?: number;
  value?: number;
  onValueChange?: (value: number) => void;
}

/**
 * Single-value slider built on a real `<input type="range">` — native touch,
 * pointer and keyboard handling plus a built-in `role="slider"` come for free,
 * so no custom-drag plumbing is needed. The active fill is value-driven through
 * the `--tcn-slider-fill` custom property; `theme.css` styles the track and
 * thumb per platform via the webkit/moz range pseudo-elements (MD3 16px thumb,
 * iOS 28px white thumb with shadow). Emits the same `tcn-slider` element as the
 * Angular component.
 *
 * MD3 extras (theme-dumb, CSS-only): a value-label bubble revealed while dragging
 * (`:has(.tcn-slider:active)` in the chrome — no drag state tracked here) and
 * tick marks for discrete (stepped) sliders.
 */
export const TcnSlider = forwardRef<HTMLInputElement, TcnSliderProps>(function TcnSlider(
  { min = 0, max = 100, step = 1, value = 0, onValueChange, disabled, className, style, ...props },
  ref,
) {
  const range = max - min;
  const percent = range > 0 ? ((value - min) / range) * 100 : 0;
  const fill = `${Math.max(0, Math.min(100, percent))}%`;

  const steps = step > 0 && range > 0 ? range / step : 0;
  const showTicks = steps >= 2 && steps <= 30;

  return (
    <div className="tcn-slider-host relative block" style={{ '--tcn-slider-fill': fill } as CSSProperties}>
      <input
        ref={ref}
        type="range"
        className={cn('tcn-slider', className)}
        min={min}
        max={max}
        step={step}
        value={value}
        disabled={disabled}
        style={{ '--tcn-slider-fill': fill, ...style } as CSSProperties}
        onChange={(event) => onValueChange?.(event.target.valueAsNumber)}
        {...props}
      />
      {showTicks && (
        <div className="tcn-slider-ticks" aria-hidden style={{ '--tcn-slider-step': `${100 / steps}%` } as CSSProperties} />
      )}
      <div className="tcn-slider-label" aria-hidden>
        {value}
      </div>
    </div>
  );
});
Source · Vue
<script setup lang="ts">
import { computed } from 'vue';
import type { CSSProperties } from 'vue';

/**
 * Single-value slider built on a real `<input type="range">` — native touch,
 * pointer and keyboard handling plus a built-in `role="slider"` come for free,
 * so no custom-drag plumbing is needed. The active fill is value-driven through
 * the `--tcn-slider-fill` custom property; `theme.css` styles the track and
 * thumb per platform via the webkit/moz range pseudo-elements (MD3 16px thumb,
 * iOS 28px white thumb with shadow). Emits the same `tcn-slider` element as the
 * Angular / React counterparts. Two-way bound with `v-model`.
 *
 * MD3 extras (theme-dumb, CSS-only): a value-label bubble revealed while dragging
 * (`:has(.tcn-slider:active)` in the chrome — no drag state tracked here) and
 * tick marks for discrete (stepped) sliders.
 */
const props = withDefaults(
  defineProps<{ min?: number; max?: number; step?: number; disabled?: boolean }>(),
  { min: 0, max: 100, step: 1 },
);

const model = defineModel<number>({ default: 0 });

const fill = computed(() => {
  const range = props.max - props.min;
  const percent = range > 0 ? ((model.value - props.min) / range) * 100 : 0;
  return `${Math.max(0, Math.min(100, percent))}%`;
});

const style = computed<CSSProperties>(() => ({ '--tcn-slider-fill': fill.value }) as CSSProperties);

const steps = computed(() => {
  const range = props.max - props.min;
  return props.step > 0 && range > 0 ? range / props.step : 0;
});
const showTicks = computed(() => steps.value >= 2 && steps.value <= 30);
const stepStyle = computed<CSSProperties>(() => ({ '--tcn-slider-step': `${100 / steps.value}%` }) as CSSProperties);
</script>

<template>
  <div class="tcn-slider-host relative block" :style="style">
    <input
      type="range"
      class="tcn-slider"
      :min="min"
      :max="max"
      :step="step"
      :value="model"
      :disabled="disabled"
      :style="style"
      @input="model = ($event.target as HTMLInputElement).valueAsNumber"
    />
    <div v-if="showTicks" class="tcn-slider-ticks" aria-hidden="true" :style="stepStyle" />
    <div class="tcn-slider-label" aria-hidden="true">{{ model }}</div>
  </div>
</template>
export { default as TcnSlider } from './TcnSlider.vue';

Last updated on July 24, 2026

Was this page helpful?