PlSlider
A value chosen along a range. The rail is a neutral groove and the run that fills it is the same gradient a button is made of.
The rail is --plass-track, the same ink a PlSwitch's off state is. It is not the glass with an inset shadow in it, which is what a filled field is: a field is a box you look into, and a rail is a line you look along, and the part of a rail that matters is the part with nothing on it, which is exactly the part a white-on-white groove does not have.
import { PlSlider } from 'plass-ui';
<PlSlider label="Volume" value={volume} onValueChange={setVolume} showValue />;import 'package:plass_ui/plass_ui.dart';
PlSlider(
label: const Text('Volume'),
values: <double>[volume],
showValue: true,
onChanged: (List<double> next) => setState(() => volume = next.first),
);Props
| Prop | Type | Default | Description |
|---|---|---|---|
| sizeshared | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | Groove thickness, thumb diameter, and the label type scale |
| colorshared | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | The gradient of the filled run, and the thumb on it |
| elevationshared | 0 | 1 | 2 | 3 | 1 | Drop shadow depth of the thumb. It is the part you press, so it takes a control default of 1 |
| orientationshared | 'horizontal' | 'vertical' | 'horizontal' | Which way the slider runs. A vertical slider has no length of its own, so give it a height |
| value | number | number[] | — | The current value. An array makes it a range slider with one thumb per entry |
| defaultValue | number | number[] | — | The starting value, uncontrolled |
| onValueChange | (value: number | number[]) => void | — | Called with the new value |
| min · max · step | number | 0 · 100 · 1 | The range and its increments, passed straight to Base UI |
| label | ReactNode | — | The label above the track |
| description | ReactNode | — | Helper text below the track |
| showValue | boolean | ((formatted, values) => ReactNode) | false | Shows the current value beside the label. Pass a function to format it |
| disabled | boolean | false | Unavailable. Loses its saturation, lets the page through, and leaves the tab order |
| name | string | — | Identifies the control when a form is submitted |
| Prop | Type | Default | Description |
|---|---|---|---|
| values * | List<double> | — | The chosen value, or the ends of the chosen range. Always a list: the length is what makes it a range |
| onChanged | ValueChanged<List<double>>? | — | Called with the new value |
| onChangeEnd | ValueChanged<List<double>>? | — | Called once, when the thumb is let go |
| min · max · step | double | 0 · 100 · 1 | The range and its increments, passed straight to Base UI |
| sizeshared | PlassSize | PlassSize.md | Groove thickness, thumb diameter, and the label type scale |
| colorshared | PlassColor | PlassColor.primary | The gradient of the filled run, and the thumb on it |
| elevationshared | int | 1 | Drop shadow depth of the thumb. It is the part you press, so it takes a control default of 1 |
| orientationshared | PlassOrientation | PlassOrientation.horizontal | Which way the slider runs. A vertical slider has no length of its own, so give it a height |
| length | double? | — | How long the run is. A vertical slider has no length of its own, so this is where one comes from — 160 by default |
| label | Widget? | — | The label above the track |
| description | Widget? | — | Helper text below the track |
| showValue | bool | false | Shows the current value beside the label. Pass a function to format it |
| formatValue | String Function(List<double>)? | — | Formats that value. Left out, it is printed with no decimals and joined with an en dash |
| disabled | bool | false | Unavailable. Loses its saturation, lets the page through, and leaves the tab order |
| semanticLabel | String? | — | The name a screen reader announces, for a slider with no visible label |
Every other prop on Base UI's Slider.Root passes straight through, minStepsBetweenValues, largeStep, format, onValueCommitted, name, disabled.
values is always a list, even for a single value: it is the same parameter either way, and the length is what makes it a range.
There is no variant here. The three materials answer "what is this surface made of", and a slider is two surfaces at once: a groove and a key travelling along it. Neither has a choice to offer.
The thumb travels to a value it was not dragged to: an arrow key, a press on the rail, or a value set from elsewhere. It moves over the same duration everything else here does, and the run behind it fills at the same rate. Under a finger it does not travel at all, because a thumb that eased towards the pointer would lag behind it. This is the one place in the library a position is animated, and it keeps the no-transform rule: what moves is the value, not the control.
What the shared axes (size color elevation orientation) mean across the library is in prop conventions.
Examples
Range
Pass more than one value and it becomes a range slider with one thumb per entry. There is no separate range prop, because the shape of the value already says which one this is.
The thumbs cannot cross: a value is held between its neighbours, so a range whose ends have swapped is a range that was entered backwards, and the fix lives here rather than in every caller.
import { useState } from 'react';
import { PlSlider } from 'plass-ui';
export default function SliderRange() {
const [price, setPrice] = useState<number[]>([25, 75]);
return (
<PlSlider
className="max-w-sm"
label="Price"
value={price}
min={0}
max={100}
onValueChange={(next) => setPrice(next as number[])}
showValue={(formatted) => `$${formatted[0]} – $${formatted[1]}`}
/>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class SliderRange extends StatefulWidget {
const SliderRange({super.key});
@override
State<SliderRange> createState() => _SliderRangeState();
}
class _SliderRangeState extends State<SliderRange> {
List<double> _price = <double>[25, 75];
@override
Widget build(BuildContext context) {
return SizedBox(
width: 384,
child: PlSlider(
label: const Text('Price'),
values: _price,
showValue: true,
formatValue: (List<double> values) =>
'\$${values.first.round()} – \$${values.last.round()}',
onChanged: (List<double> next) => setState(() => _price = next),
),
);
}
}color
The filled run is the family's gradient, the same two-stop sweep at 135° a solid button carries, and the thumb sits on it, ringed in the page's own surface colour so it never dissolves into the run behind it.
import { PlSlider } from 'plass-ui';
export default function SliderColors() {
return (
<div className="flex w-full max-w-sm flex-col gap-5">
{(['primary', 'success', 'warning', 'danger'] as const).map((color) => (
<PlSlider key={color} color={color} label={color} defaultValue={60} showValue />
))}
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class SliderColors extends StatelessWidget {
const SliderColors({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 384,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
spacing: 20,
children: <Widget>[
for (final color in <PlassColor>[
PlassColor.primary,
PlassColor.success,
PlassColor.warning,
PlassColor.danger,
])
PlSlider(
color: color,
label: Text(color.name),
values: const <double>[60],
showValue: true,
onChanged: (List<double> next) {},
),
],
),
);
}
}min · max · step
step decides what the thumb can land on. A slider with five stops is still a slider and not a segmented control: it is chosen by dragging, and the values are on a scale.
import { PlSlider } from 'plass-ui';
export default function SliderSteps() {
return (
<div className="flex w-full max-w-sm flex-col gap-5">
<PlSlider label="Continuous" defaultValue={40} showValue />
<PlSlider label="In tens" defaultValue={40} step={10} showValue />
<PlSlider
label="1 to 5"
defaultValue={3}
min={1}
max={5}
step={1}
showValue
description="Every step is a whole number, so the thumb snaps."
/>
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class SliderSteps extends StatelessWidget {
const SliderSteps({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 384,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
spacing: 20,
children: <Widget>[
PlSlider(
label: const Text('Continuous'),
values: const <double>[40],
showValue: true,
onChanged: (List<double> next) {},
),
PlSlider(
label: const Text('In tens'),
values: const <double>[40],
step: 10,
showValue: true,
onChanged: (List<double> next) {},
),
PlSlider(
label: const Text('1 to 5'),
values: const <double>[3],
min: 1,
max: 5,
showValue: true,
description: const Text('Every step is a whole number, so the thumb snaps.'),
onChanged: (List<double> next) {},
),
],
),
);
}
}showValue
true prints the raw value; a function is handed both Base UI's already-localised strings and the raw numbers, so a currency, a percentage or a duration is one line.
showValue turns the number on and formatValue decides what it says, a currency, a percentage, a duration. Left out, the values are printed with no decimals and joined with an en dash.
The value sits at the end of the label's row rather than following the thumb. A number that moves is a number that is hard to read and impossible to compare between two sliders stacked on each other.
size
Moves the groove, the thumb and the label together. The thumb is deliberately far bigger than the groove at every step. It is the only part of the control you can actually catch, and a thumb sized to match a 6px rail is a thumb nobody hits on a touchscreen.
import { PlSlider } from 'plass-ui';
export default function SliderSizes() {
return (
<div className="flex w-full max-w-sm flex-col gap-5">
{(['xs', 'sm', 'md', 'lg', 'xl'] as const).map((size) => (
<PlSlider key={size} size={size} label={size} defaultValue={55} showValue />
))}
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class SliderSizes extends StatelessWidget {
const SliderSizes({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 384,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
spacing: 20,
children: <Widget>[
for (final size in PlassSize.values)
PlSlider(
size: size,
label: Text(size.name),
values: const <double>[55],
showValue: true,
onChanged: (List<double> next) {},
),
],
),
);
}
}orientation
A vertical slider has no length of its own, so it is given one: 160px by default. Override it with a class`length` overrides it when a mixer needs taller faders.
import { PlSlider } from 'plass-ui';
export default function SliderOrientation() {
return (
<div className="flex items-end gap-8">
<PlSlider orientation="vertical" defaultValue={30} aria-label="Bass" />
<PlSlider orientation="vertical" defaultValue={65} aria-label="Mid" />
<PlSlider orientation="vertical" defaultValue={48} aria-label="Treble" />
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class SliderOrientation extends StatefulWidget {
const SliderOrientation({super.key});
@override
State<SliderOrientation> createState() => _SliderOrientationState();
}
class _SliderOrientationState extends State<SliderOrientation> {
final Map<String, double> _bands = <String, double>{'Bass': 30, 'Mid': 65, 'Treble': 48};
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
spacing: 32,
children: <Widget>[
for (final band in _bands.keys)
PlSlider(
orientation: PlassOrientation.vertical,
semanticLabel: band,
values: <double>[_bands[band]!],
onChanged: (List<double> next) => setState(() => _bands[band] = next.first),
),
],
);
}
}disabled
The light going out, as everywhere else: the shape and the position stay, the saturation and half the opacity go.
import { PlSlider } from 'plass-ui';
export default function SliderStates() {
return (
<div className="flex w-full max-w-sm flex-col gap-5">
<PlSlider label="Default" defaultValue={45} showValue />
<PlSlider label="Disabled" defaultValue={45} showValue disabled />
</div>
);
}import 'package:flutter/widgets.dart';
import 'package:plass_ui/plass_ui.dart';
class SliderStates extends StatelessWidget {
const SliderStates({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 384,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
spacing: 20,
children: <Widget>[
PlSlider(
label: const Text('Default'),
values: const <double>[45],
showValue: true,
onChanged: (List<double> next) {},
),
const PlSlider(
label: Text('Disabled'),
values: <double>[45],
showValue: true,
disabled: true,
),
],
),
);
}
}Accessibility
- Each thumb is a real
<input type="range">, so the browser's own slider semantics, the tab order anddisabledall come for free. labelis wired to the control by Base UI. Without one, a fader in a bank of them, give the slider anaria-label.- The keyboard is the primitive's: ← → ↑ ↓ step, PageUp / PageDown take the large step, Home and End jump to the ends.
- The whole strip is a pointer target, not just the rail: the control box is several times the groove's thickness, so a press anywhere along it moves the thumb.
- The thumb grows a halo on hover and while dragging rather than growing itself. Nothing under the finger is ever scaled.
showValueis a rendered number, not a substitute for the accessible value. That isaria-valuenowon the input, which Base UI keeps in step.
- Announced as a slider, with the current value as its value. Without a visible
label, a fader in a bank of them, give it asemanticLabel. - ← → ↑ ↓ move a thumb by one
step, PageUp / PageDown by a tenth of the range, and Home and End jump to the ends. - Each thumb is its own focus stop, which is what makes a range slider operable: Tab moves between the two ends.
- The whole strip is a pointer target, not just the rail: the control box is several times the groove's thickness, so a press anywhere along it moves the nearest thumb.
- The thumb grows a halo on hover and while dragging rather than growing itself. Nothing under the finger is ever scaled.
showValueis a drawn number, not a substitute for the announced one.
Differences from the React build
| React | Flutter | Why |
|---|---|---|
value as a number or an array | values, always a list | One parameter either way, and the length is what makes it a range. |
onValueChange / onValueCommitted | onChanged / onChangeEnd | Flutter's names for "as it moves" and "when it is let go". |
showValue as boolean-or-function | showValue and formatValue | Dart has no union type, so turning the number on and deciding what it says are two parameters. |
<input type="range"> | a drawn strip with its own key handling | There is no native range input to inherit a keyboard from, so the keys are bound here, the same set, including Page and Home/End. |
aria-label | semanticLabel | Flutter's name. |
className for a vertical slider's height | length | There is no class list; the length is a parameter. |