About this animation
A start-and-end interval picker with time wheels and validation, with configurable time zone, minute step, palette, and fonts.
How it works
- Store two epoch-millisecond instants. Duration always uses their real difference, including daylight saving transitions.
- Open either row's date or time control. Hour, minute, and AM/PM wheels each have one UI-thread position; text position, scale, and opacity derive from that position. Pan velocity feeds an interruptible spring. Tapping a neighboring row selects it without dragging.
- Resolve civil edits through
resolveLocal. Missing times are rejected; repeated times display an explicit choice of instants with UTC offsets. Invalid interval drafts remain visible; an invalid civil edit stays in its editor until corrected or canceled. - Keep validation independent of the editing view. Move end forward sets
end = start + minDurationMinutesand emits one change. It never silently advances a calendar day. - Apply confirms only a valid interval. Cancel restores the last confirmed value. Compact hosts replace the summary with an editing view, with Back and an internal scroll owner; larger hosts retain the summary above it.
- The correction's border, duration roll, and progress line resolve together over 180 ms. Springs and timings are original art direction. The private reference is a static sequence, with no measured animation timings. Night neutrals follow composite JPEG measurements; lavender and Paper are original adaptations.
Install
Copy the folder, excluding demo/, into your project:
npx expo install expo-blur expo-linear-gradient react-native-gesture-handler react-native-reanimated react-native-workletsWrap your app in GestureHandlerRootView. Expo SDK 58 configures the Reanimated Babel integration. No date dependency, native date picker, images, fonts, or global theme provider is required. The host supplies safe areas once. The demo alone uses expo-image, the repository fonts, and react-native-safe-area-context.
Usage
import { DateTimeIntervalPicker } from './date-time-interval-picker';
const start = new Date(2026, 9, 22, 9).getTime();
<DateTimeIntervalPicker
timeZone={Intl.DateTimeFormat().resolvedOptions().timeZone}
defaultValue={{ start, end: start + 90 * 60_000 }}
onConfirm={(interval) => saveSession(interval)}
/>;Controlled value and personality knobs:
const [interval, setInterval] = useState({ start, end: start + 90 * 60_000 });
const picker = useRef<DateTimeIntervalPickerHandle>(null);
<DateTimeIntervalPicker
ref={picker}
value={interval}
onChange={(draft, { valid }) => {
setInterval(draft); // Keep invalid drafts visible, too.
}}
timeZone={Intl.DateTimeFormat().resolvedOptions().timeZone}
hourCycle={12}
minuteStep={15}
minDurationMinutes={15}
maxDurationMinutes={480}
colorScheme="dark"
palette={{ accent: '#C6B8FF', onAccent: '#21183B' }}
motion={{ durationScale: 1, glowOpacity: 0.12 }}
labels={{ title: 'Plan your session', start: 'Starts', end: 'Ends' }}
onValidationChange={({ valid, code }) => updateValidation(valid, code)}
/>;
// An explicit repair, never an automatic change to a civil date:
picker.current?.adjustEnd();Import useState, useRef, and DateTimeIntervalPickerHandle in the second example; replace the application's save/validation handlers.
Time zones and DST
Without resolveLocal, editing is limited to the exact zone returned by Intl.DateTimeFormat().resolvedOptions().timeZone. Other zones show a readable adapter error and disable Apply. Zone changes reformat the same instants and preserve elapsed duration. Even equivalent aliases require an adapter when they differ from the device zone string.
For another zone, provide a synchronous adapter:
resolveLocal={(civil, zone) => ({
instants: resolveWithYourTimeZoneService(civil, zone),
})}The adapter returns all matching instants, zero for a gap, and both occurrences for a fold. Results are deduplicated, sorted, checked for finite range, and checked against the requested civil date in the selected zone. Errors reject the edit. The component never chooses one occurrence silently. formatAmbiguous can customize occurrence buttons; keep an offset or another clear distinction in their text. Tests use a New York spring gap and fall fold; this is not a claim of exhaustive historical time-zone coverage.
The built-in device resolver samples surrounding offsets to detect current DST gaps/folds. It is intended for contemporary scheduling. Supply your own adapter for historical rules or unusual transitions. The component never adds 24 hours to navigate a civil day; numeric month/day/year inputs resolve the requested date directly.
12 AM maps to 00:00, 12 PM to 12:00. Changing AM/PM preserves minutes. A supplied off-grid minute remains an available wheel choice. Zero/negative durations, short months, leap dates, NaN/Infinity, unsupported instants, and inconsistent limits produce validation errors. Supported civil editing years are 1–9999. Incoming instants outside that calendar-year range should be restricted by the host's bounds.
Props
| Prop | Type | Default | Purpose |
|---|---|---|---|
value / defaultValue | Interval | Current whole hour + 90 min | Controlled draft / initial uncontrolled draft. Controlled props always win. |
onChange | (value, meta) => void | — | Discrete edit; meta includes field, reason, and valid. No notification when the instants are unchanged. |
onConfirm | (value) => void | — | Valid interval only; does not dismiss the entire component. |
onValidationChange | (Validation) => void | — | Initial validation and changes of validity or error code. |
timeZone | string | Required | Explicit IANA zone. |
locale | string | en-US | Intl date and time locale. Invalid formats fall back to the supplied invalid label. |
hourCycle | 12 | 24 | 12 | Hour format and meridiem wheel. |
minuteStep | 1 | 5 | 10 | 15 | 30 | 5 | Invalid runtime steps fall back to 5. |
minDurationMinutes | number | 15 | Positive minimum and explicit repair size. |
maxDurationMinutes | number | Unbounded | Must be at least the minimum. |
minInstant / maxInstant | number | Date range bounds | Inclusive interval limits; inconsistent bounds are errors. |
disabled | boolean | false | Blocks interaction and ref methods except reset. |
resolveLocal | (LocalDateTime, zone) => { instants } | Device resolver | Civil-time adapter; all occurrences required. |
formatDate / formatTime | (instant, zone, locale) => string | Intl | Row text. |
formatDuration | (minutes) => string | English hours/minutes | Elapsed duration, including a visible negative draft. |
formatAmbiguous | (instant, zone, locale) => string | Intl with UTC offset | Occurrence labels. |
labels | Partial<Labels> | Neutral English | Title, row names, duration, Apply/Back/Cancel, repair, gap/fold, increment/decrement, AM/PM, date/time axis labels, success, and a validation(code) formatter. |
colorScheme | light | dark | auto | auto | Paper / Night / system. |
palette | Partial<Palette> | Theme tokens | background, surface, elevated, text, muted, border, accent, onAccent, accentSoft, glow, success, error, scrim, track, focus. |
fontFamily / titleFontFamily / actionFontFamily / monoFontFamily | string | System | Body / heading / semibold actions / numeric text. |
motion | { durationScale?, glowOpacity? } | 1, .12 | Clamped to 0.5–2 and 0–0.3; non-finite values use defaults. |
style | StyleProp<ViewStyle> | — | Root flex container override, applied last. |
testID / accessibilityLabel | string | Pattern ID / title | Stable root and control ID prefixes / root description. |
The handle exposes edit(field, part), setValue(interval), adjustEnd(), close(), confirm(), and reset(). field is start or end; part is date or time. Reset requests the mount-time baseline; in controlled mode it emits one request and waits for the host. It does not replace the last confirmed value. close() and Back exit editing and keep discrete selections; Cancel or Escape restores the last confirmed interval. The host may reject any controlled request without the component overriding it.
A nonexistent or unresolved ambiguous civil edit has no single epoch representation, so it blocks confirmation and emits validation without inventing an onChange instant. Selecting an occurrence emits one change. An invalid epoch interval does emit onChange with valid: false.
Compatibility
Expo SDK 58, React 19, React Native 0.88.0, Reanimated 4.7.1, Worklets 0.13.0, Gesture Handler 3.3, expo-blur 58.0.5, expo-linear-gradient 58.0.3. Designed for iOS, Android, and web, including Expo Go with these bundled modules.
Android blur uses BlurTargetView and dimezisBlurViewSdk31Plus; older Android versions receive the solid surface fallback. Both blur and wrappers have their own clipping radius. No Skia or raster asset is needed. Compilation does not measure device FPS or certify physical touch behavior; those require a device run.
Accessibility
Buttons have at least 48 × 48 effective targets, visible keyboard focus, and selected/disabled states. Wheels expose adjustable values and increment/decrement accessibility actions. Tap the neighboring wheel row, drag, or use Up/Down while the wheel has keyboard focus. Date inputs work without dragging. Tab/Enter work on actions; Escape cancels. Back restores focus to the original row. Validation uses text as well as color and a polite live region; native confirmation announces once.
Reduced motion is queried initially and observed live. It removes springs and rolling movement, retaining every value and action. Each component measures its host; the internal ScrollView owns overflow, including large fonts. At 200% font size, time axes stack in the editing view rather than shrinking targets. No font-scaling limits or essential ellipses are used. The host controls safe areas and may embed the picker in a 300 × 400 region. RTL uses logical row direction while chronological wheel order stays vertical.
Contract tests (development only, Node 22):
TZ=America/New_York node --experimental-strip-types --test src/animations/date-time-interval-picker/interval.test.mjsThese cover real durations, invalid constraints, leap dates, New York DST gaps/folds, and zone reformatting. They do not measure rendering FPS or physical gestures.
Why this one
A date range is not a timed interval. This editor keeps both dates, both times, real elapsed duration, and validation in one contract. Invalid drafts stay inspectable, repairs are explicit, and the controlled host remains in charge.
Demo brand: Aurora from the local brand kit, recolored lavender. Icons: Lucide (ISC). No reference assets, proprietary text, or photos are included. Raster assets: N/A.





