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';