Date Ranges,
That Finally Flow.
Built for products that move through time.
RangeFlow combines a smooth date slider, preset ranges, and a popover calendar in one component, with TypeScript support and styling that stays inside the picker.
- Whole Foods06:04groceriesPending−€28.59$33.74
- New Balance14:27sport−€40.14$47.37
- Lyft13:56transport−€7.99$9.43
- Chipotle04:38food−€19.96$23.55
- Whole Foods13:02groceries−€25.21$29.75
- New Balance12:13sport−€58.51$69.04
- Walmart09:08groceries−€31.40$37.05
- Amtrak05:23transport−€26.76$31.58
- New Balance19:18sport−€24.30$28.67
- Uber10:35transport−€9.67$11.41
Install
One package. Zero global styles.
RangeFlow ships a single component and a single CSS file scoped to .rangeflow-date-picker. Import once at the root of your app. Nothing leaks.
$ npm install rangeflow// app/layout.tsx
import 'rangeflow/style.css'Quick start
Two dates. One callback.
The defaultRange prop sets the visible window. The defaultSelected prop is what the user picks inside it. The onChangecallback fires with the new selection. That's the whole API surface for the common case.
import { RangeFlow } from 'rangeflow'
import 'rangeflow/style.css'
import dayjs from 'dayjs'
export function Activity() {
const [range, setRange] = useState({
from: dayjs().subtract(7, 'day').toDate(),
to: dayjs().toDate()
})
return (
<RangeFlow
defaultRange={{
from: dayjs().subtract(90, 'day').toDate(),
to: dayjs().toDate()
}}
defaultSelected={range}
ranges={[
{ label: '7d', from: dayjs().subtract(6, 'day').toDate(), to: dayjs().toDate() },
{ label: '30d', from: dayjs().subtract(29, 'day').toDate(), to: dayjs().toDate() }
]}
onChange={setRange}
/>
)
}Why rangeflow
A picker that actually moves.
Drag-native range slider
Built for mouse and touch. The whole window is grabbable, both handles snap with weight.
Quick-range tabs
Pre-defined windows like 7 / 30 / 90 days with an animated active pill.
Popover calendar
A real calendar one click away. Choose single or multi-month.
One-variable theming
Set --rangeflow-accent and the picker matches the rest of your design system.
Slot-based composition
Replace any visible part. Swap tabs, tickers, value labels, or the selected-date readout.
Imperative API
The useRangeflow() hook exposes updateRange / updateSelectedDates for forms, URLs, and external buttons.
Props
Every prop, at a glance.
Three required props get you a working picker. The rest narrow the surface: bounds, disabled ranges, presets, slots, imperative control.
| Prop | Type | Default | Description |
|---|---|---|---|
| defaultRange* | { from: Date; to: Date } | N/A | The outer window the slider spans. The user drags the selection inside this range. |
| defaultSelected* | { from: Date; to: Date } | N/A | The initial selected range. Must fall inside defaultRange. |
| onChange* | (date: { from: Date; to: Date }) => void | N/A | Fires whenever the user drags the slider or picks a date from the calendar. |
| ranges | RangeListItem[] | 5 built-in presets | Quick range tabs shown above the slider. Clicking a tab resets the outer window to that preset. |
| duration | { min: number; max: number } | N/A | Min / max number of days the selection can span. Applied as soft clamps while dragging. |
| disabled | { before: Date } | { after: Date } | { before; after } | N/A | Disable dates outside a boundary. Applies to both slider and calendar. |
| calendar | boolean | true | Show the popover calendar trigger next to the selected date label. |
| CalendarProps | DayPickerProps | N/A | Forwarded directly to the underlying react-day-picker. |
| Slots | Slots | N/A | Override individual parts (RangeTabs, DateTickers, SelectedDate, SliderValueLabel, DateLabelsTrack). See the Slots section. |
| api | RangeFlowApi | N/A | Imperative handle from useRangeflow() for programmatic control. See the Hook section. |
Minimal example
Only three props are required. Everything else has a sensible default.
<RangeFlow
defaultRange={{ from: lastMonth, to: today }}
defaultSelected={{ from: lastWeek, to: today }}
onChange={setRange}
/>Duration: Min / max days
Clamp how many days the selection can cover. Drag the slider. It won't shrink below min or grow beyond max.
selected 7 days
<RangeFlow
defaultRange={window}
defaultSelected={selected}
duration={{ min: 3, max: 30 }}
onChange={setRange}
/>Disabled: Bound the selectable range
Block selection before / after a cutoff. Applies to both slider and calendar. Typical use: lock to the past for analytics.
// Lock to past. Common for analytics.
<RangeFlow
defaultRange={window}
defaultSelected={selected}
disabled={{ after: new Date() }}
onChange={setRange}
/>Ranges: Quick preset tabs
Preset tabs above the slider. Clicking a tab re-fits the outer window and selects that range.
<RangeFlow
defaultRange={window}
defaultSelected={selected}
ranges={[
{ label: '7d', from: sub(today, 6, 'day'), to: today },
{ label: '30d', from: sub(today, 29, 'day'), to: today },
{ label: '90d', from: sub(today, 89, 'day'), to: today }
]}
onChange={setRange}
/>Calendar: Toggle the popover picker
Hide the popover calendar when the slider is enough. This keeps the UI compact on dense dashboards.
<RangeFlow
defaultRange={window}
defaultSelected={selected}
calendar={false}
onChange={setRange}
/>Hook
Drive it from outside with useRangeflow().
An imperative handle for when the picker needs to react to things it doesn't own. URL params, other filters, reset buttons, and keyboard shortcuts all fit here.
Mental model
- Uncontrolled by default. The component owns its state;
onChangetells you about changes. - The hook is the escape hatch. When external state needs to push into the picker, pass the api handle.
- Don't reach for it first. If
onChangecovers your case, skip the hook.
import { RangeFlow, useRangeflow } from 'rangeflow'
export function Picker() {
const api = useRangeflow()
return (
<>
<button onClick={() => api.updateSelectedDates({ from, to })}>
Last 7 days
</button>
<RangeFlow
api={api}
defaultRange={{ from, to }}
defaultSelected={{ from, to }}
onChange={setRange}
/>
</>
)
}| Method | Signature | Description |
|---|---|---|
| updateRange | (range: { from: Date; to: Date }) => void | Replace the outer window the slider spans. The current selection re-fits inside the new window. Does not call onChange. |
| updateSelectedDates | (dates: { from: Date; to: Date }) => void | Move the slider thumb to a new selection inside the current window. Emits onChange with the new value. |
Jump the selection with updateSelectedDates
Move the selection from outside the component. Common for quick-action buttons, keyboard shortcuts, or syncing with URL params.
selected: 13 Apr → 20 Apr
const api = useRangeflow()
<button onClick={() =>
api.updateSelectedDates({
from: dayjs().subtract(6, 'day').toDate(),
to: dayjs().toDate()
})
}>
Last 7 days
</button>
<RangeFlow api={api} defaultRange={window} defaultSelected={initial} onChange={setRange} />Change the window with updateRange
Swap the outer window. The current selection re-fits inside. Use this for zoom / time-scale controls.
const api = useRangeflow()
<button onClick={() =>
api.updateRange({
from: dayjs().subtract(89, 'day').toDate(),
to: dayjs().toDate()
})
}>
Zoom to 90 days
</button>
<RangeFlow api={api} defaultRange={window} defaultSelected={initial} onChange={setRange} />Slots
Swap any part. Keep the rest.
Each slot is a full replacement. Pass a React component and it renders in place of the built-in. Use the CSS tokens --rangeflow-* so custom content stays on-theme.
| Slot | Props | Description |
|---|---|---|
| SelectedDate | { from: string; to: string } | The formatted date label in the picker bar (left of the range tabs). Receives pre-formatted strings. |
| SliderValueLabel | { label: string } | The floating pill above the slider thumb. Receives the default label (e.g. "7 Days"). |
| DateTickers | N/A | The vertical ticks rendered across the slider track. Replace with any visualization (heatmap, sparkline, etc). |
| DateLabelsTrack | N/A | The date labels row beneath the slider. Replace to change formatting, density, or layout. |
| RangeTabs | N/A | The preset tabs row in the picker bar. Full replacement. You own selection state if you override this. |
SliderValueLabel
The pill above the slider thumb. Receives the default label. Turn it into a badge, icon, or anything else.
function CustomLabel({ label }: { label: string }) {
const days = parseInt(label, 10) || 1
return (
<div className="flex items-center gap-1.5 rounded-full bg-[var(--rangeflow-accent-solid)] px-2.5 py-1 text-[11px] font-semibold text-[var(--rangeflow-accent-contrast)]">
<span>{days === 1 ? '🌤️' : days < 7 ? '📆' : '📊'}</span>
{label}
</div>
)
}
<RangeFlow
defaultRange={window}
defaultSelected={selected}
Slots={{ SliderValueLabel: CustomLabel }}
onChange={setRange}
/>SelectedDate
The date display in the picker bar. Receives pre-formatted strings so you don't re-implement formatting.
function CustomSelectedDate({ from, to }: { from: string; to: string }) {
return (
<div className="flex items-center gap-2 text-xs font-medium">
<span className="rounded-md bg-[var(--rangeflow-hover-bg)] px-2 py-0.5 text-[var(--rangeflow-accent-text)]">{from}</span>
<span>→</span>
<span className="rounded-md bg-[var(--rangeflow-hover-bg)] px-2 py-0.5 text-[var(--rangeflow-accent-text)]">{to}</span>
</div>
)
}
<RangeFlow Slots={{ SelectedDate: CustomSelectedDate }} … />DateTickers
The tick row on the slider track. Ignore it, turn it into a gradient, or render a heatmap of your data.
function HeatmapTickers() {
return (
<div className="flex w-full items-center gap-[2px]">
{cells.map(i => (
<div key={i} className="h-3 flex-1 rounded-[1px]"
style={{ backgroundColor: colorFor(i) }}
/>
))}
</div>
)
}
<RangeFlow Slots={{ DateTickers: HeatmapTickers }} … />DateLabelsTrack
The date labels under the slider. It is absolutely positioned. Keep `absolute top-10 left-0` on your replacement.
function MinimalLabels() {
return (
<div className="absolute top-10 left-0 flex w-full justify-between px-2 text-[10px] tracking-[0.18em] uppercase text-[var(--rangeflow-text-faint)]">
<span>start</span><span>·</span><span>·</span><span>·</span><span>now</span>
</div>
)
}
<RangeFlow Slots={{ DateLabelsTrack: MinimalLabels }} … />Theming
One variable. The rest is derived.
Set --rangeflow-accent and every border, hover, range, and ring re-balances via color-mix(). Override individual tokens only when you want finer control.
:root {
--rangeflow-accent: #4f46e5;
}
/* Or scope to one instance */
.my-picker {
--rangeflow-accent: #4f46e5;
}{/* Any of these enables dark mode */}
<div className="dark"><RangeFlow {...props} /></div>
<div data-theme="dark"><RangeFlow {...props} /></div>
{/* Works with parents too. Tailwind's dark: prefix integrates out of the box. */}
<html className="dark">
<body><RangeFlow {...props} /></body>
</html>Live playground
Pick a color, toggle dark mode. The accent drives border, hover, range, ring, and ticker. Everything stays balanced.
#4f46e5<div style={{ '--rangeflow-accent': '#4f46e5' }}>
<RangeFlow {...props} />
</div><div
data-theme={dark ? 'dark' : 'light'}
style={{ '--rangeflow-accent': accent }}
>
<RangeFlow {...props} />
</div>Tokens
Set any of these on the picker root or any ancestor. Core tokens drive the system; derived tokens are available when you need to break out of the defaults.
--rangeflow-accent | #16433C | Brand color. Drives most other tokens. |
--rangeflow-surface | #ffffff / #0a0f0c | Background of the picker (light / dark). |
--rangeflow-foreground | #0a0f0c / #ffffff | Text base color (light / dark). |
--rangeflow-on-accent | auto | Text rendered on solid accent (auto black/white). |
--rangeflow-font | system stack | Font family used inside the picker. |
--rangeflow-bg | = surface | Inner background. |
--rangeflow-border | mix | Default border. |
--rangeflow-border-strong | mix | Stronger border. |
--rangeflow-shadow-color | mix | Shadow tint. |
--rangeflow-text | mix | Main text. |
--rangeflow-text-muted | mix | Secondary text. |
--rangeflow-text-subtle | mix | Labels. |
--rangeflow-text-faint | mix | Separators and faint labels. |
--rangeflow-text-disabled | mix | Disabled items. |
--rangeflow-hover-bg | mix | Hover background. |
--rangeflow-range-bg | mix | Background of the picked range. |
--rangeflow-active-bg | mix | Active tab pill background. |
--rangeflow-accent-solid | = accent | Solid accent fills. |
--rangeflow-accent-solid-hover | mix | Hover for solid accent. |
--rangeflow-accent-contrast | = on-accent | Text on solid accent. |
--rangeflow-accent-text | mix | Tinted accent text (e.g. selected date). |
--rangeflow-ring | = accent | Focus ring. |
--rangeflow-separator | mix | Separator lines. |
--rangeflow-separator-active | = accent | Separator on active state. |
--rangeflow-ticker | mix | Tick marks on the slider. |
--rangeflow-today | = accent-text | The "today" marker on the calendar. |
Class names
Style without tokens when you need to.
Every scoped class starts with .rangeflow-. Prefer CSS variables first. Reach for classes only when you need structural changes (spacing, sizing, layout).
.rangeflow-date-picker | Root of the picker. All scoped styles live under here. |
.rangeflow-date-picker-portal | Root of any portalled part (calendar popover). |
.rangeflow-root | Outer wrapper (inside the root). |
.rangeflow-header | Top bar holding the date and tabs. |
.rangeflow-body | Bottom area with the slider. |
.rangeflow-slider | Slider track container. |
.rangeflow-tabs | Range tabs container. |
.rangeflow-tab | One tab button. |
.rangeflow-tab-indicator | Animated active tab pill. |
.rangeflow-selected-date | The current selection label. |
Overriding safely
Always scope your selectors to .rangeflow-date-picker so nothing leaks into the rest of your app.
/* Targets only RangeFlow, not your app */
.rangeflow-date-picker .rangeflow-tab {
border-radius: 999px;
font-weight: 600;
}
.rangeflow-date-picker .rangeflow-selected-date {
letter-spacing: 0.02em;
}