time_ring_picker
A highly customizable circular clock-style time range picker for Flutter. Drag two handles around a 24-hour dial to pick a start and end time — built for things like sleep schedules, quiet hours, or any "from X to Y" time range.
- Full 24-hour dial in a single rotation, numbers
1–12(or0–22) shown around the circle. - Overnight ranges just work:
09:00 PM→06:00 AMreports a9hduration, no manual date math. - 12h and 24h label formats.
- Deep customization: colors, gradients, handle icons, ring shadows/glow, text styles, tick marks, number placement.
- Accessible out of the box — each handle works with screen readers and switch control.
- No dependencies beyond the Flutter SDK. Works on mobile, web, and desktop.
Requires Dart >= 3.13.0 (bundled with any recent Flutter stable release). Pure Dart/Flutter — no native setup, no platform channels, nothing to configure per platform.
| Default style | Fully customized style, 24h format | Day & Night style |
|---|---|---|
![]() |
![]() |
![]() |
All three pickers above are straight from example/lib/main.dart — run it yourself to try every option below live.
Install
dependencies:
time_ring_picker: <latest_version>
flutter pub get
Quick start
import 'package:flutter/material.dart';
import 'package:time_ring_picker/time_ring_picker.dart';
TimeRingPicker(
initialStartTime: const TimeOfDay(hour: 21, minute: 0),
initialEndTime: const TimeOfDay(hour: 6, minute: 0),
onTimeChanged: (startTime, endTime, duration) {
print(startTime); // 21:00
print(endTime); // 06:00
print(duration); // 9:00:00.000000
},
)
That's it — no controller, no setup required.
New here? Read "Understanding the dial" below before you start customizing colors and formats — it explains the two things that aren't obvious at a glance: how overnight duration is calculated, and why the
1–12numbers can mean two different times.
Understanding the dial
The dial maps a full 24-hour day onto one 360° rotation (15° per hour), so every angle maps to exactly one time of day — there's no ambiguity between AM and PM at the math level. Duration is always computed forward from start to end, wrapping past midnight when needed:
// start: 21:00, end: 06:00
// diffMinutes = (06:00 - 21:00) = -900 -> +1440 -> 540 minutes -> 9h
If start and end land on the same time, the range is treated as a full 24h selection (a zero-width arc isn't a meaningful range) rather than 0h.
About the 1–12 numbers: in TimeFormat.h12, only 12 numbers fit around the dial, so each position is shared by two clock hours 12 hours apart (e.g. the 9 mark is either 09:00 or 21:00). style.showAmPmIndicator: true (the default) renders a small AM/PM tag next to each handle disambiguating which one it's on — the handle's own time label is always unambiguous regardless. Switch to TimeFormat.h24 and this goes away entirely: every position gets its own unique hour (0, 2, 4, ... 22), so there's nothing to disambiguate.
Callbacks
TimeRingPicker(
initialStartTime: const TimeOfDay(hour: 21, minute: 0),
initialEndTime: const TimeOfDay(hour: 6, minute: 0),
onDragStart: (handle) {
// handle is TimeRingHandle.start or TimeRingHandle.end
},
onTimeChanged: (startTime, endTime, duration) {
// fires continuously while dragging
},
onDragEnd: (startTime, endTime, duration) {
// fires once when the drag ends
},
)
Customizing the look
TimeRingPicker(
initialStartTime: const TimeOfDay(hour: 22, minute: 30),
initialEndTime: const TimeOfDay(hour: 5, minute: 45),
timeFormat: TimeFormat.h24,
minuteInterval: 15,
style: const TimeRingPickerStyle(
size: 280,
borderWidth: 18,
backgroundColor: Color(0xFF1B1D28),
gradientColors: [Color(0xFFFF7E5F), Color(0xFFFEB47B)],
handleColor: Colors.white,
handleSize: 24,
handleBorderColor: Color(0xFFFF7E5F),
durationTextStyle: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Colors.white,
),
startEndTimeTextStyle: TextStyle(fontSize: 13, color: Colors.white70),
showTicks: false,
),
)
TimeRingPickerStyle field |
Controls |
|---|---|
size |
Dial diameter |
padding |
Space reserved around the dial |
borderWidth |
Stroke width of the ring/arc |
backgroundColor |
Unselected track color |
selectedArcColor |
Solid fill for the selected arc (used when gradientColors is null) |
gradientColors |
Sweep-gradient colors for the selected arc |
handleColor / handleBorderColor / handleBorderWidth |
Handle fill/border |
handleSize |
Handle diameter |
textColor |
Fallback text color |
numberStyle |
Style of the 1–12 clock numbers |
durationTextStyle |
Style of the center duration label (e.g. 9h) |
startEndTimeTextStyle |
Style of the start/end time labels |
showAmPmIndicator |
Show/hide the small AM/PM tag near each handle (h12 mode only) |
amPmIndicatorBackgroundColor |
Background pill color behind the AM/PM tag (keeps it legible over nearby clock numbers). Defaults to backgroundColor |
showTicks / tickColor |
Minor tick marks around the dial |
numberPosition |
NumberPosition.outside (default, numbers past the ring) or NumberPosition.inside (numbers between the ring and the center text — smaller overall footprint) |
startHandleIcon / endHandleIcon |
IconData? drawn inside each handle instead of a plain circle (e.g. Icons.bedtime / Icons.wb_sunny). null (default) keeps plain circles |
handleIconColor / handleIconSize |
Color/size of the handle icons, when set. Default to handleBorderColor and 55% of handleSize |
ringShadows |
List<BoxShadow>? flat-color drop shadow(s) behind the ring — same type as BoxDecoration.boxShadow. null (default) draws no shadow |
arcGlowBlurRadius / arcGlowSpread / arcGlowOpacity |
Colored "neon" glow behind the selected arc, reusing its own color/gradient (see below). arcGlowBlurRadius: null (default) draws no glow |
Custom handle icons
style: const TimeRingPickerStyle(
startHandleIcon: Icons.bedtime,
endHandleIcon: Icons.wb_sunny,
handleIconColor: Colors.orange,
)
Ring shadow
Two options, depending on the look you want:
-
ringShadows— a flat-color drop shadow behind the whole ring, likeBoxDecoration.boxShadow:style: const TimeRingPickerStyle( ringShadows: [ BoxShadow(color: Color(0x66FF7E5F), blurRadius: 20, spreadRadius: 2), ], ) -
arcGlowBlurRadius— a colored "neon tube" glow behind just the selected arc, automatically matching itsgradientColors/selectedArcColor(no separate color to configure):style: const TimeRingPickerStyle( gradientColors: [Color(0xFFFFF3B0), Color(0xFFFF9E2C)], arcGlowBlurRadius: 18, arcGlowSpread: 10, arcGlowOpacity: 0.55, )
Both can extend past the ring — make sure padding leaves enough room, or a tightly-constrained parent may clip it.
Programmatic control
Supply your own TimeRingController to read or set the selection outside of drag gestures:
final controller = TimeRingController(
initialStartTime: const TimeOfDay(hour: 21, minute: 0),
initialEndTime: const TimeOfDay(hour: 6, minute: 0),
);
TimeRingPicker(
initialStartTime: controller.selection.startTime,
initialEndTime: controller.selection.endTime,
controller: controller,
onTimeChanged: (start, end, duration) {},
)
// Elsewhere:
controller.setStartTime(const TimeOfDay(hour: 22, minute: 0));
Platform support
Pure Dart/Flutter, no platform channels — works anywhere Flutter runs: Android, iOS, web, macOS, Windows, Linux. Drag gestures use GestureDetector's pan callbacks, which already unify touch, mouse, and trackpad input — nothing platform-specific to set up.
Performance
The dial is painted in two layers, each in its own RepaintBoundary:
- Background (ring, ticks, numbers) — only repaints when
style/timeFormatchange. - Foreground (selected arc, handles, center text) — repaints every drag frame.
Dragging a handle never re-lays-out the clock numbers, so it stays smooth even with a heavily customized style.
Accessibility
Each handle exposes an invisible Semantics node with a label (Start time/End time), its current formatted value, and increase/decrease actions — screen readers and switch control can adjust the selection by minuteInterval without needing to perform a drag gesture.
Troubleshooting
Duration shows 24h when I expect 0h. Start and end landing on the same time is treated as a full day selected, not an empty range — see "Understanding the dial" above. If you need a true zero-width state, check startTime == endTime yourself before reading duration.
A handle feels hard to grab / drags the wrong handle. Touch targets are generous by default (handleSize * 2, min 28px), and picking is based on real on-screen distance, not just angle — so this is usually the two handles sitting close together (small minSelectionDuration gap). Increase handleSize or minSelectionDuration if it's still awkward.
The AM/PM tag or a handle icon looks clipped at the edge. Increase style.padding — numbers, the AM/PM tag, and handles are all allowed to paint slightly past the ring's nominal radius, and padding is what reserves room for that.
ringShadows/arcGlowBlurRadius isn't visible. Same cause as above (not enough padding), or the color's alpha is too low — ringShadows colors need their own opacity baked in (e.g. Color(0x66FF7E5F)), while arcGlowOpacity handles that for arcGlowBlurRadius automatically.
onTimeChanged isn't firing. It only fires from user drags (and TimeRingController calls). Setting initialStartTime/initialEndTime on a rebuild does nothing once the widget has mounted — use a TimeRingController and call setStartTime/setEndTime/setSelection if you need to change the value programmatically.
Contributing
Issues and PRs welcome. Run flutter analyze and flutter test before submitting.
Bugs & Credits
Report bugs and ask questions on GitHub Issues. Maintained by Dashstack Infotech, Surat.
Libraries
- time_ring_picker
- A highly customizable circular clock-style time range picker.


