devletes / filament-chrono-picker
The ultimate date and time picker for Filament: time, date, month, year and range pickers with a clock face, calendar grids and Filament's own look.
Package info
github.com/devletes/filament-chrono-picker
pkg:composer/devletes/filament-chrono-picker
Requires
- php: ^8.2
- filament/filament: ^5.0
Requires (Dev)
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0 || ^11.0
- phpunit/phpunit: ^11.0 || ^12.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Time, date, date-time, month, year and range pickers for Filament that look like Filament's own: a clock face with hour and minute dials, a calendar whose month and year open as grids, and ranges that store into the columns you name.
| Single value | Range |
|---|---|
TimePicker |
TimeRangePicker |
DatePicker |
DateRangePicker |
DateTimePicker |
|
MonthPicker |
MonthRangePicker |
YearPicker |
YearRangePicker |
- Drop-in replacements.
TimePicker,DatePickerandDateTimePickerextend Filament's own, so swapping the import is the whole migration;MonthPickerandYearPickerextend Filament'sDatePickertoo - Ranges stored your way. Two columns you name (
starts_on/ends_on), or one JSON column with keys you name, never a guess - No dependencies. No jQuery, no date library, no build step — vanilla JavaScript on the Alpine you already have
- Loaded on request. The script and stylesheet are fetched by the first field that needs them, so a page without one loads nothing
- The dial follows the field.
g:i Agets a 12-hour face with AM/PM,H:igets a 24-hour face with an inner ring - Every minute is reachable, not every fifth, and the steps still coarsen it when you want that
- Bounds that hold.
minTime()/maxTime()dim the dial, clamp typing, and are re-checked on the server - A calendar to match, with min and max dates, disabled dates and your locale's month and weekday names; click the month or the year for a grid of either
- Themed off Filament's palette — follows your panel's colour, light and dark
![]() |
![]() |
Requirements
- PHP
^8.2 - Filament
^5.0on Laravel 12 or 13
Installation
composer require devletes/filament-chrono-picker
Then publish Filament's assets so the stylesheet and script are served on your panels:
php artisan filament:assets
The package registers its stylesheet and script with Filament's asset manager as on-request assets: neither is in the page's <head>, and the first picker on a page fetches both (Filament's x-load and x-load-css). There is nothing to add to your custom Tailwind theme. Run filament:assets again after every update of the package, as for any Filament plugin.
Moving from devletes/filament-time-picker
Chrono Picker is the time picker grown into a full date and time suite, published under a new name. Swap the requirement:
composer remove devletes/filament-time-picker composer require devletes/filament-chrono-picker php artisan filament:assets
- The namespace is now
Devletes\ChronoPicker. The old class names (Devletes\TimePicker\Forms\Components\TimePickerand friends) still resolve to the new classes, with a deprecation notice, until Chrono Picker 1.0. - The config file is
config/chrono-picker.php. A publishedconfig/time-picker.phpis still read until you rename it. - Views, translations and publish tags are
devletes-chrono-picker(they weredevletes-time-picker); re-publish anything you had published. - CSS classes and theme variables are
fi-chrono-*/--fi-chrono-*(they werefi-dtp-*). - The Alpine component is
devletesChronoPicker, loaded as a module on request rather than registered on every page. getClockConfig()is nowgetPickerConfig(); the old name still answers.- The footer's shortcut reads "Today" on a date-only field (it read "Now").
Quick start
use Devletes\ChronoPicker\Forms\Components\DatePicker; use Devletes\ChronoPicker\Forms\Components\DateTimePicker; use Devletes\ChronoPicker\Forms\Components\TimePicker; TimePicker::make('deliver_at') ->seconds(false) ->displayFormat('g:i A'); DatePicker::make('delivered_on'); DateTimePicker::make('ships_at') ->seconds(false) ->displayFormat('M j, Y g:i A'); MonthPicker::make('billing_month'); // stored as 2026-11 YearPicker::make('fiscal_year'); // stored as 2026 DateRangePicker::make('window') ->storeIn(start: 'starts_on', end: 'ends_on');
(MonthPicker, YearPicker and the range classes live in the same Devletes\ChronoPicker\Forms\Components namespace.)
A drop-in replacement
Each component extends Filament's own, so swapping the import is the whole migration:
-use Filament\Forms\Components\TimePicker; +use Devletes\ChronoPicker\Forms\Components\TimePicker; -use Filament\Forms\Components\DatePicker; +use Devletes\ChronoPicker\Forms\Components\DatePicker; -use Filament\Forms\Components\DateTimePicker; +use Devletes\ChronoPicker\Forms\Components\DateTimePicker;
Everything you already use still works and still means the same thing — seconds(), the steps, format(), displayFormat(), timezone(), live(), disabled(), the affixes and the validation. The state cast is Filament's own, so nothing about what is stored changes.
The dial follows the field
Say nothing, and the face is read off the display format. A field that writes its hour as 1–12 gets the 12-hour face with an AM/PM switch; anything else gets the 24-hour face, with 13–23 and midnight on an inner ring.
TimePicker::make('deliver_at')->displayFormat('g:i A'); // 12-hour face TimePicker::make('cutoff')->displayFormat('H:i'); // 24-hour face TimePicker::make('cutoff')->hoursCycle(24); // say so yourself
![]() |
![]() |
![]() |
![]() |
Set chrono-picker.hours_cycle in the config to make one face the rule everywhere.
Minutes and seconds
Picking an hour moves to the minute dial. The dial takes every minute: the numerals are drawn at the fives, but dragging between them lands on 9:07 the way you would expect. A small label above the pin says which unit you are setting.
TimePicker::make('slot')->minutesStep(15); // quarter hours only TimePicker::make('slot')->hoursStep(2);
![]() |
![]() |
seconds() adds a third dial, and secondsStep() coarsens it the same way.
TimePicker::make('loaded_at') ->seconds() ->displayFormat('H:i:s');
![]() |
![]() |
Bounds
TimePicker::make('pickup') ->minTime('09:00') ->maxTime('17:00');
Numerals outside the range are dimmed and refuse the pointer, typing is pulled back inside, and the same bound is checked again on the server — the panel is not the only way a value arrives. The message names the bound in the field's own format ("must be no earlier than 9:00 AM"). Both accept a string, a Carbon instance, or a closure.
![]() |
![]() |
Date and time
DateTimePicker pairs the same clock with a calendar. Picking a day moves straight to the clock, and the switch at the top of the panel moves between the two.
DateTimePicker::make('ships_at') ->seconds(false) ->displayFormat('M j, Y g:i A') ->minDate(now()->startOfDay()) ->maxDate(now()->addMonths(3)) ->disabledDates(['2026-12-25', '2026-12-26']) ->minTime('08:00') ->maxTime('20:00');
![]() |
![]() |
![]() |
![]() |
minDate() and maxDate() disable the days outside them, and on the first and last day their times narrow the clock too. minTime() / maxTime() stay a daily window, applied on every day. Month and weekday names follow the field's locale.
Filament's other date options apply as they do there:
DateTimePicker::make('ships_at') ->weekStartsOnSunday() // or firstDayOfWeek(0–7) ->defaultFocusedDate(now()->addWeek()) // the month an empty field opens on ->closeOnDateSelection(); // pick a day and close, without the clock
Dates
DatePicker is the calendar alone: Filament's DatePicker, stored as Y-m-d and shown as M j, Y unless you say otherwise. Every date option above applies. (DateTimePicker::make(...)->time(false) is the same field.)
DatePicker::make('delivered_on') ->minDate(now()->startOfDay()) ->maxDate(now()->addYears(2)->endOfYear());
![]() |
![]() |
Month and year grids
Every calendar's header is the month and the year between two arrows. Click the month for a grid of the twelve months of that year, whose arrows step a year at a time. Click the year (on either view) for a grid of twelve years, whose arrows page through earlier and later dozens. Picking in a grid goes back to the view it was opened from, so a date three years away is three clicks. Months and years wholly outside minDate() / maxDate() are dimmed and refuse the pointer; the current month and year are marked in the accent colour.
![]() |
![]() |
Months and years
MonthPicker picks a month of a year and YearPicker a year. Both are Filament's DatePicker underneath, so required(), minDate(), maxDate(), format(), displayFormat(), default(), live() and validation behave as they do there.
| Stored as | Shown as | Opens on | |
|---|---|---|---|
MonthPicker |
Y-m (2026-11) |
M Y (Nov 2026) |
the twelve months of the year, under a year that opens the year grid |
YearPicker |
Y (2026) |
Y |
a page of twelve years, with arrows to page |
MonthPicker::make('billing_month') ->maxDate(now()->addMonths(6)); YearPicker::make('fiscal_year') ->minDate(2015) ->maxDate(now()->addYear()); MonthPicker::make('period')->format('m/Y')->displayFormat('F Y'); // store and show as you like
Bounds are whole periods: minDate('2026-03') allows March onwards and maxDate(2027) allows all of 2027, on the server as in the panel. A bound may be a Y-m or Y string, an integer year, a Carbon instance or a closure. Reading a stored 2026-02 on the 31st of a month stays February (Filament's own cast would roll it into March), and a year stored as an integer column reads as well as a string.
![]() |
![]() |
![]() |
![]() |
Ranges
DateRangePicker, MonthRangePicker, YearRangePicker and TimeRangePicker put both ends of a range in one field: "Jan 1, 2026 – Jan 20, 2026", "Jan 2026 – Jun 2026", "2018 – 2026", "7:30 AM – 4:00 PM". Each end is the matching single picker, so it reads, stores and is bounded exactly as that picker would be.
Where the range is stored
You say where, every time; there is no default.
Two columns (the common case): the field reads both ends from the attributes you name and writes both back. Its own name is only the field's, and is not saved.
DateRangePicker::make('window') ->storeIn(start: 'starts_on', end: 'ends_on');
On an Eloquent record, starts_on and ends_on are the record's columns; inside a Repeater (with or without ->relationship()), each item's own; on a Livewire component with no model, the keys beside the field in its state. The form's state comes back as ['starts_on' => '2026-01-01', 'ends_on' => '2026-01-20'].
One JSON column: both ends in an array under the field's own name, with the keys you name. Give the model an array (or json) cast; a JSON string is read too.
DateRangePicker::make('closure') ->storeAsJson(start: 'from', end: 'to'); // ['from' => '2026-12-24', 'to' => '2027-01-02']
An empty range saves two nulls in columns, or null in JSON. A field that says neither throws a LogicException naming both methods as soon as it is filled or rendered.
Each end is stored in its picker's format: Y-m-d for dates, Y-m for months, Y for years, H:i (or H:i:s with seconds()) for times. format() changes it for both ends.
Layout
Side by side (the default): From on the left, To on the right, both open at once, with no band painted between them. Each panel's heading says what it holds.
DateRangePicker::make('window')->storeIn(start: 'starts_on', end: 'ends_on');
![]() |
![]() |
One at a time: a From / To switch over a single panel. Picking From moves the panel on to To. Clicking the From or To half of the field's text opens that end.
DateRangePicker::make('closure') ->storeAsJson(start: 'from', end: 'to') ->oneAtATime(); // or ->layout(RangeLayout::OneAtATime) / ->layout('one-at-a-time')
![]() |
![]() |
Either way, the To panel opens where From is and dims what comes before it; picking a From after the current To clears To so it is picked again; each end is saved as soon as it is picked, so OK or a click outside just closes the panel, Cancel puts back the range it opened on, and Clear empties both ends. closeOnComplete() closes as soon as To is picked.
Months, years and times
MonthRangePicker::make('contract') ->storeIn(start: 'contract_starts', end: 'contract_ends'); // 2026-01 / 2026-06 YearRangePicker::make('service') ->storeIn(start: 'service_from', end: 'service_to') ->oneAtATime(); // 2018 / 2026 TimeRangePicker::make('shift') ->storeIn(start: 'shift_starts', end: 'shift_ends') ->seconds(false) ->displayFormat('g:i A') ->minTime('06:00') ->maxTime('22:00'); // 07:30 / 16:00
![]() |
![]() |
![]() |
![]() |
A time range is within one day: the end may not be before the start, so a shift past midnight is two fields.
Validation
On the server, whatever arrived:
- both ends or neither (
required()asks for both); - the end not before the start (the same moment is allowed);
minDate()/maxDate()(orminTime()/maxTime()) on each end, as whole periods for months and years.
Messages name the end by its label: "To must not be before From." Rename the ends with startLabel() / endLabel(), which also relabel the panel.
Options
The single pickers' options are passed on to both ends: format(), displayFormat(), minDate(), maxDate(), minTime(), maxTime(), disabledDates(), defaultFocusedDate(), firstDayOfWeek(), weekStartsOnMonday(), weekStartsOnSunday(), timezone(), locale(), hoursCycle(), seconds(), hoursStep(), minutesStep(), secondsStep(). Anything else a single picker offers, per end:
DateRangePicker::make('window') ->storeIn(start: 'starts_on', end: 'ends_on') ->configureEndsUsing(fn (DatePicker $end, string $which) => $end->defaultFocusedDate(now()->addMonth()));
rangeSeparator(' to ') changes the dash between the two halves of the text.
Typing and the keyboard
The big digits in the panel are inputs. Click one and type; it moves to the minute on its own when the hour cannot be extended. The arrow keys nudge by the field's own step, Escape closes the panel, and Backspace on the closed field clears it.
Closing and the footer
By default the panel waits for OK. To save and close as soon as the last dial is set (the minute, or the second when seconds are on), and hide OK:
TimePicker::make('call_back_at')->closeOnComplete();
The footer's buttons are Filament's own. Relabel them, or drop the "Now" shortcut:
TimePicker::make('deliver_at') ->okLabel('Set time') ->cancelLabel('Back') ->nowLabel('Right now') ->nowButton(false);
On phones
native() hands rendering back to Filament, which gives the browser's own control — on a phone the platform's picker is the better choice:
TimePicker::make('deliver_at') ->native(fn (): bool => str_contains((string) request()->userAgent(), 'Mobile'));
Configuration
config/chrono-picker.php sets the defaults every field starts from:
php artisan vendor:publish --tag=devletes-chrono-picker-config
return [ 'hours_cycle' => null, // 12, 24, or null to read it off each field's display format 'close_on_complete' => false, // save and close once the last dial, day, month or year is picked 'now_button' => true, // show the "Now" / "Today" shortcut ];
Theming
Every colour resolves through Filament's palette variables, so the picker follows your panel's primary colour and its light and dark themes without being told. To change it further, override the --fi-chrono-* variables in your theme stylesheet:
html .fi-chrono { --fi-chrono-accent: var(--success-600); --fi-chrono-plate: var(--gray-50); } html.dark .fi-chrono { --fi-chrono-accent: var(--success-400); }
The variables are surface, plate (the dial), border, text, muted, faded, accent, on-accent and hover.
Or publish the views to change the markup:
php artisan vendor:publish --tag=devletes-chrono-picker-views
Translations
Every user-facing string goes through devletes-chrono-picker::chrono-picker.*. Publish the language files to reword them or add a locale:
php artisan vendor:publish --tag=devletes-chrono-picker-translations
Then edit or add files under lang/vendor/devletes-chrono-picker/.
API reference
Everything Filament's TimePicker, DatePicker and DateTimePicker offer, plus:
| Method | What it does |
|---|---|
hoursCycle(12|24) |
Fixes the face rather than reading it off the display format |
minTime($time) |
The earliest time of day the field will take. String, Carbon, or closure |
maxTime($time) |
The latest time of day the field will take. String, Carbon, or closure |
closeOnComplete() |
Save and close once the last dial is set, and hide OK |
nowButton(false) |
Drops the "Now" shortcut from the footer |
okLabel($label) |
Relabels the confirm button |
cancelLabel($label) |
Relabels the cancel button |
nowLabel($label) |
Relabels the "Now" shortcut |
native() |
Hands rendering back to Filament for the browser's own control |
On DatePicker and DateTimePicker, Filament's minDate(), maxDate(), disabledDates(), firstDayOfWeek(), weekStartsOnMonday(), weekStartsOnSunday(), defaultFocusedDate(), closeOnDateSelection(), time() and seconds() all apply to the calendar and the clock. On MonthPicker and YearPicker, minDate(), maxDate() and defaultFocusedDate() take whole periods.
The range classes (DateRangePicker, MonthRangePicker, YearRangePicker, TimeRangePicker) are Filament fields of their own, with everything a field has (label(), required(), disabled(), readOnly(), placeholder(), helperText(), live(), columnSpan()...) and:
| Method | What it does |
|---|---|
storeIn(start: 'a', end: 'b') |
Stores the ends in two attributes beside the field |
storeAsJson(start: 'from', end: 'to') |
Stores the ends in one array under the field's name, with these keys |
sideBySide() / oneAtATime() / layout($layout) |
How the two ends share the panel (RangeLayout) |
startLabel($label) / endLabel($label) |
Names the ends (default From / To) in the panel and in messages |
rangeSeparator($separator) |
The text between the two halves of the field (default –) |
configureEndsUsing(fn ($end, $which) => ...) |
Configures each end's single picker directly |
closeOnComplete(), okLabel(), cancelLabel() |
As on the single pickers |
| The single pickers' bounds, formats and steps | Passed on to both ends (see Ranges → Options) |
Testing
composer install
composer test
The suite runs on Testbench with the real Filament stack, against PHP 8.2–8.4 on Laravel 12 and 13: every picker's stored shape and bounds, and the ranges on an Eloquent record, in a JSON column, inside a relationship repeater and on plain Livewire state.
The workbench (composer serve, then /admin/deliveries) shows every component on one form. With it running, npm run e2e drives the ranges in a real browser and npm run screenshots regenerates the images in this README.
Need something custom?
We build production Filament panels and plugins for teams that want to ship fast without compromising on polish. If you need a custom feature, an extended variant of this package, or a fully bespoke component built for your stack, we can help.
- Browse the rest of our Filament work: filament.devletes.com
- Get in touch: salman@devletes.com
Typical engagements: new Filament plugins, custom resources/widgets/actions, theme + UX work, integrations with your existing services, and one-off tailored forks of our open-source packages.
Have you seen our Orbit theme?
Orbit is our dark-first theme for Filament 5. Every panel surface is restyled — sidebar, topbar, forms, tables, modals, notifications.
Credits
License
MIT. See LICENSE.md.































