Range Inputs
Ported from HyperUI’s Range Inputs — see Attribution.
class RangeInputs1Example extends StatefulWidget { const RangeInputs1Example({super.key});
@override State<RangeInputs1Example> createState() => _RangeInputs1ExampleState();}
class _RangeInputs1ExampleState extends State<RangeInputs1Example> { static const _min = 0.0; static const _max = 100.0; double _value = 20;
void _updateFromDx(double dx, double width) { if (width <= 0) return; var fraction = (dx / width).clamp(0.0, 1.0); if (Directionality.of(context) == TextDirection.rtl) { fraction = 1 - fraction; } setState(() { _value = (_min + fraction * (_max - _min)).roundToDouble(); }); }
@override Widget build(BuildContext context) { return Style( 'mx-auto max-w-sm p-6', children: [ const Style( 'block text-sm font-medium text-gray-900', child: Text('Max Volume'), ), LayoutBuilder( builder: (context, constraints) { return GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: (details) => _updateFromDx(details.localPosition.dx, constraints.maxWidth), onHorizontalDragUpdate: (details) => _updateFromDx(details.localPosition.dx, constraints.maxWidth), child: Style( 'mt-3 h-3.5 w-full appearance-none rounded-full bg-gray-300 [&::-webkit-slider-thumb]:size-7 [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-[6px] [&::-webkit-slider-thumb]:border-gray-500 [&::-webkit-slider-thumb]:bg-gray-200', cursor: SystemMouseCursors.click, value: _value, min: _min, max: _max, ), ); }, ), ], ); }}<label for="maxVolume"> <span class="block text-sm font-medium text-gray-900">Max Volume</span>
<input type="range" name="maxVolume" id="maxVolume" min="0" max="100" value="20" class="mt-3 h-3.5 w-full appearance-none rounded-full bg-gray-300 [&::-webkit-slider-thumb]:size-7 [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-[6px] [&::-webkit-slider-thumb]:border-gray-500 [&::-webkit-slider-thumb]:bg-gray-200" /></label>Base with output
Section titled “Base with output”class RangeInputs2Example extends StatefulWidget { const RangeInputs2Example({super.key});
@override State<RangeInputs2Example> createState() => _RangeInputs2ExampleState();}
class _RangeInputs2ExampleState extends State<RangeInputs2Example> { static const _min = 0.0; static const _max = 100.0; double _value = 20;
void _updateFromDx(double dx, double width) { if (width <= 0) return; var fraction = (dx / width).clamp(0.0, 1.0); if (Directionality.of(context) == TextDirection.rtl) { fraction = 1 - fraction; } setState(() { _value = (_min + fraction * (_max - _min)).roundToDouble(); }); }
@override Widget build(BuildContext context) { return Style( 'mx-auto max-w-sm p-6', children: [ const Style( 'block text-sm font-medium text-gray-900', child: Text('Max Volume'), ), Style( 'mt-3 flex items-center gap-3', children: [ LayoutBuilder( builder: (context, constraints) { return GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: (details) => _updateFromDx( details.localPosition.dx, constraints.maxWidth, ), onHorizontalDragUpdate: (details) => _updateFromDx( details.localPosition.dx, constraints.maxWidth, ), child: Style( 'h-3.5 w-full appearance-none rounded-full bg-gray-300 [&::-webkit-slider-thumb]:size-7 [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-[6px] [&::-webkit-slider-thumb]:border-gray-500 [&::-webkit-slider-thumb]:bg-gray-200', cursor: SystemMouseCursors.click, value: _value, min: _min, max: _max, ), ); }, ), Style( 'text-sm/none font-medium text-gray-700', child: Text('${_value.round()}%'), ), ], ), ], ); }}<label for="maxVolume"> <span class="block text-sm font-medium text-gray-900">Max Volume</span>
<div class="mt-3 flex items-center gap-3"> <input type="range" name="maxVolume" id="maxVolume" min="0" max="100" value="20" class="h-3.5 w-full appearance-none rounded-full bg-gray-300 [&::-webkit-slider-thumb]:size-7 [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-[6px] [&::-webkit-slider-thumb]:border-gray-500 [&::-webkit-slider-thumb]:bg-gray-200" />
<span class="text-sm/none font-medium text-gray-700">20%</span> </div></label>Base with min and max labels
Section titled “Base with min and max labels”class RangeInputs3Example extends StatefulWidget { const RangeInputs3Example({super.key});
@override State<RangeInputs3Example> createState() => _RangeInputs3ExampleState();}
class _RangeInputs3ExampleState extends State<RangeInputs3Example> { static const _min = 0.0; static const _max = 100.0; double _value = 20;
void _updateFromDx(double dx, double width) { if (width <= 0) return; var fraction = (dx / width).clamp(0.0, 1.0); if (Directionality.of(context) == TextDirection.rtl) { fraction = 1 - fraction; } setState(() { _value = (_min + fraction * (_max - _min)).roundToDouble(); }); }
@override Widget build(BuildContext context) { return Style( 'mx-auto max-w-sm p-6', children: [ const Style( 'block text-sm font-medium text-gray-900', child: Text('Max Volume'), ), LayoutBuilder( builder: (context, constraints) { return GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: (details) => _updateFromDx(details.localPosition.dx, constraints.maxWidth), onHorizontalDragUpdate: (details) => _updateFromDx(details.localPosition.dx, constraints.maxWidth), child: Style( 'mt-3 h-3.5 w-full appearance-none rounded-full bg-gray-300 [&::-webkit-slider-thumb]:size-7 [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-[6px] [&::-webkit-slider-thumb]:border-gray-500 [&::-webkit-slider-thumb]:bg-gray-200', cursor: SystemMouseCursors.click, value: _value, min: _min, max: _max, ), ); }, ), Style( 'mt-1 flex items-center justify-between', children: [ const Style('text-xs font-medium text-gray-700', child: Text('0%')), const Style( 'text-xs font-medium text-gray-700', child: Text('100%'), ), ], ), ], ); }}<label for="maxVolume"> <span class="block text-sm font-medium text-gray-900">Max Volume</span>
<input type="range" name="maxVolume" id="maxVolume" min="0" max="100" value="20" class="mt-3 h-3.5 w-full appearance-none rounded-full bg-gray-300 [&::-webkit-slider-thumb]:size-7 [&::-webkit-slider-thumb]:cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-[6px] [&::-webkit-slider-thumb]:border-gray-500 [&::-webkit-slider-thumb]:bg-gray-200" />
<div class="mt-1 flex items-center justify-between"> <span class="text-xs font-medium text-gray-700">0%</span> <span class="text-xs font-medium text-gray-700">100%</span> </div></label>Native with tick marks
Section titled “Native with tick marks”class RangeInputs4Example extends StatefulWidget { const RangeInputs4Example({super.key});
@override State<RangeInputs4Example> createState() => _RangeInputs4ExampleState();}
class _RangeInputs4ExampleState extends State<RangeInputs4Example> { static const _min = 0.0; static const _max = 100.0; static const _thumbDiameter = 14.0; static const _trackHeight = 8.0; static const _trackColorUnfilled = Color(0xFFEFEFEF); static const _trackColorFilled = Color(0xFF0075FF); static const _tickColor = Color(0xFF000000); static const _ticks = [0, 25, 50, 75, 100]; static const _trackTopInset = 4.0;
double _value = 20;
void _updateFromDx(double dx, double width) { final usable = width - _thumbDiameter; if (usable <= 0) return; var fraction = ((dx - _thumbDiameter / 2) / usable).clamp(0.0, 1.0); if (Directionality.of(context) == TextDirection.rtl) { fraction = 1 - fraction; } setState(() { _value = (_min + fraction * (_max - _min)).roundToDouble(); }); }
Widget _buildNativeTrack(double width, bool isRtl) { final usable = width - _thumbDiameter; final valueFraction = ((_value - _min) / (_max - _min)).clamp(0.0, 1.0); final fraction = isRtl ? 1 - valueFraction : valueFraction; final thumbCenter = _thumbDiameter / 2 + fraction * usable; final fillLeft = isRtl ? thumbCenter : 0.0; final fillWidth = isRtl ? width - thumbCenter : thumbCenter; return SizedBox( height: _thumbDiameter + 12, child: Stack( clipBehavior: Clip.none, children: [ Positioned( left: 0, right: 0, top: (_thumbDiameter - _trackHeight) / 2 + _trackTopInset, child: Container( height: _trackHeight, decoration: BoxDecoration( color: _trackColorUnfilled, borderRadius: BorderRadius.circular(_trackHeight / 2), ), ), ), Positioned( left: fillLeft, top: (_thumbDiameter - _trackHeight) / 2 + _trackTopInset, child: Container( width: fillWidth, height: _trackHeight, decoration: BoxDecoration( color: _trackColorFilled, borderRadius: BorderRadius.circular(_trackHeight / 2), ), ), ), for (final tick in _ticks) Positioned( left: (_thumbDiameter / 2 + (tick / 100) * usable - 0.5) .roundToDouble(), top: _thumbDiameter + _trackTopInset, child: Container(width: 1, height: 5, color: _tickColor), ), Positioned( left: thumbCenter - _thumbDiameter / 2, top: _trackTopInset, child: Container( width: _thumbDiameter, height: _thumbDiameter, decoration: const BoxDecoration( color: _trackColorFilled, shape: BoxShape.circle, ), ), ), ], ), ); }
@override Widget build(BuildContext context) { final isRtl = Directionality.of(context) == TextDirection.rtl; return Style( 'mx-auto max-w-sm p-6', children: [ const Style( 'block text-sm font-medium text-gray-900', child: Text('Max Volume'), ), Style('mt-1 w-full', value: _value, min: _min, max: _max), LayoutBuilder( builder: (context, constraints) { return GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: (details) => _updateFromDx(details.localPosition.dx, constraints.maxWidth), onHorizontalDragUpdate: (details) => _updateFromDx(details.localPosition.dx, constraints.maxWidth), child: _buildNativeTrack(constraints.maxWidth, isRtl), ); }, ), ], ); }}<label for="maxVolume"> <span class="block text-sm font-medium text-gray-900">Max Volume</span>
<input type="range" name="maxVolume" id="maxVolume" min="0" max="100" value="20" list="volumeTicks" class="mt-1 w-full" />
<datalist id="volumeTicks"> <option value="0"></option> <option value="25"></option> <option value="50"></option> <option value="75"></option> <option value="100"></option> </datalist></label>Native with label marks
Section titled “Native with label marks”class RangeInputs5Example extends StatefulWidget { const RangeInputs5Example({super.key});
@override State<RangeInputs5Example> createState() => _RangeInputs5ExampleState();}
class _RangeInputs5ExampleState extends State<RangeInputs5Example> { static const _min = 0.0; static const _max = 100.0; static const _thumbDiameter = 14.0; static const _trackHeight = 8.0; static const _trackColorUnfilled = Color(0xFFEFEFEF); static const _trackColorFilled = Color(0xFF0075FF); static const _tickColor = Color(0xFF000000); static const _ticks = [0, 25, 50, 75, 100]; static const _trackTopInset = 4.0; static const _labelTopInset = 16.0;
double _value = 20;
void _updateFromDx(double dx, double width) { final usable = width - _thumbDiameter; if (usable <= 0) return; var fraction = ((dx - _thumbDiameter / 2) / usable).clamp(0.0, 1.0); if (Directionality.of(context) == TextDirection.rtl) { fraction = 1 - fraction; } setState(() { _value = (_min + fraction * (_max - _min)).roundToDouble(); }); }
Widget _buildNativeTrack(double width, bool isRtl) { final usable = width - _thumbDiameter; final valueFraction = ((_value - _min) / (_max - _min)).clamp(0.0, 1.0); final fraction = isRtl ? 1 - valueFraction : valueFraction; final thumbCenter = _thumbDiameter / 2 + fraction * usable; final fillLeft = isRtl ? thumbCenter : 0.0; final fillWidth = isRtl ? width - thumbCenter : thumbCenter; return SizedBox( height: _thumbDiameter + 28, child: Stack( clipBehavior: Clip.none, children: [ Positioned( left: 0, right: 0, top: (_thumbDiameter - _trackHeight) / 2 + _trackTopInset, child: Container( height: _trackHeight, decoration: BoxDecoration( color: _trackColorUnfilled, borderRadius: BorderRadius.circular(_trackHeight / 2), ), ), ), Positioned( left: fillLeft, top: (_thumbDiameter - _trackHeight) / 2 + _trackTopInset, child: Container( width: fillWidth, height: _trackHeight, decoration: BoxDecoration( color: _trackColorFilled, borderRadius: BorderRadius.circular(_trackHeight / 2), ), ), ), for (final tick in _ticks) Positioned( left: (_thumbDiameter / 2 + (tick / 100) * usable - 0.5) .roundToDouble(), top: _thumbDiameter + _trackTopInset, child: Container(width: 1, height: 5, color: _tickColor), ), for (final tick in _ticks) Positioned( left: _thumbDiameter / 2 + (tick / 100) * usable - 5, top: _thumbDiameter + _labelTopInset, child: RotatedBox( quarterTurns: 3, child: Text( '$tick', style: const TextStyle(fontSize: 10, color: Colors.black87), ), ), ), Positioned( left: thumbCenter - _thumbDiameter / 2, top: _trackTopInset, child: Container( width: _thumbDiameter, height: _thumbDiameter, decoration: const BoxDecoration( color: _trackColorFilled, shape: BoxShape.circle, ), ), ), ], ), ); }
@override Widget build(BuildContext context) { final isRtl = Directionality.of(context) == TextDirection.rtl; return Style( 'mx-auto max-w-sm p-6', children: [ const Style( 'block text-sm font-medium text-gray-900', child: Text('Max Volume'), ), Style('mt-1 w-full', value: _value, min: _min, max: _max), const Style( 'flex w-full flex-col justify-between [writing-mode:vertical-lr]', ), LayoutBuilder( builder: (context, constraints) { return GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: (details) => _updateFromDx(details.localPosition.dx, constraints.maxWidth), onHorizontalDragUpdate: (details) => _updateFromDx(details.localPosition.dx, constraints.maxWidth), child: _buildNativeTrack(constraints.maxWidth, isRtl), ); }, ), ], ); }}<label for="maxVolume"> <span class="block text-sm font-medium text-gray-900">Max Volume</span>
<input type="range" name="maxVolume" id="maxVolume" min="0" max="100" value="20" list="volumeTicks" class="mt-1 w-full" />
<datalist id="volumeTicks" class="flex w-full flex-col justify-between [writing-mode:vertical-lr]" > <option value="0" label="0"></option> <option value="25" label="25"></option> <option value="50" label="50"></option> <option value="75" label="75"></option> <option value="100" label="100"></option> </datalist></label>