Date picker
A calendar where a range fills outwards from the date chosen first.
Customize
import {Scene, Video} from "odori";
import {DatePicker} from "../components/date-picker/date-picker";
<Video>
<Scene id="date-picker" duration="6s">
<DatePicker
month="August 2026"
picks={12}
through={19}
label="Release window"
/>
</Scene>
</Video>Installation
pnpm odori add component date-pickernpx odori add component date-pickerbunx odori add component date-pickerEdit the source in videos/components/date-picker/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/date-picker/date-picker.tsx
import {Easing, Fill, interpolate, spring, useBrand, useDesignScale, useFrame, useVideo} from "odori";
export type DatePickerProps = {
/** The month heading. */
month?: string;
/** Weekday of the first, 0 for Monday. */
startsOn?: number;
/** How many days the month has. */
days?: number;
/** The day that gets picked. */
picks?: number;
/** A second day, for a range. Zero for a single date. */
through?: number;
/** The label above the calendar. */
label?: string;
openAt?: number;
};
const WEEK = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
/**
* A calendar with a date being chosen.
*
* A range fills between its ends rather than appearing whole, so the second
* click is seen to reach back to the first. Without that the range is just a
* differently coloured block and nobody can tell which end was chosen first.
*/
export const DatePicker = ({
month = "August 2026",
startsOn = 5,
days = 31,
picks = 12,
through = 19,
label = "Release window",
openAt = 20,
}: DatePickerProps) => {
const frame = useFrame();
const {fps} = useVideo();
const brand = useBrand();
const scale = useDesignScale();
const px = (value: number) => value * scale;
const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});
const opened = spring({frame, fps, delayInFrames: openAt, stiffness: 210, damping: 18});
const first = interpolate(frame, [openAt + 22, openAt + 30], [0, 1], {easing: Easing.standard});
// The range fills from the first date outwards to the second.
const reach = interpolate(frame, [openAt + 40, openAt + 62], [0, 1], {easing: Easing.standard});
const edge = through > picks ? picks + (through - picks) * reach : picks;
return (
<Fill style={{alignItems: "center", background: brand.colors.background, justifyContent: "center", padding: px(120)}}>
<div style={{fontFamily: brand.typography.sans, opacity: enter}}>
<div style={{color: brand.colors.muted, fontSize: px(20), marginBottom: px(12)}}>{label}</div>
<div
style={{
background: brand.colors.background,
border: `${px(1)}px solid ${brand.colors.border}`,
borderRadius: px(14),
opacity: opened,
padding: px(30),
transform: `scale(${0.96 + opened * 0.04})`,
}}
>
<div style={{color: brand.colors.foreground, fontSize: px(24), fontWeight: 500, marginBottom: px(22), textAlign: "center"}}>
{month}
</div>
<div style={{display: "grid", gap: px(6), gridTemplateColumns: "repeat(7, 1fr)"}}>
{WEEK.map((day) => (
<span key={day} style={{color: brand.colors.muted, fontSize: px(17), padding: px(8), textAlign: "center"}}>
{day}
</span>
))}
{Array.from({length: startsOn}, (_, index) => (
<span key={`pad-${index}`} />
))}
{Array.from({length: days}, (_, index) => {
const day = index + 1;
const inRange = through > picks && day > picks && day <= edge;
const isStart = day === picks;
const isEnd = through > picks && day === through && reach >= 1;
const on = isStart ? first : isEnd ? 1 : inRange ? 1 : 0;
return (
<span
key={day}
style={{
background: isStart || isEnd ? `color-mix(in srgb, #FAFAFA ${on * 100}%, transparent)` : inRange ? "#1E1E24" : "transparent",
borderRadius: px(8),
color: isStart || isEnd ? (on > 0.5 ? "#09090B" : "#FAFAFA") : "#C8C8CE",
fontSize: px(21),
padding: `${px(12)}px 0`,
textAlign: "center",
transform: `scale(${isStart ? 0.9 + first * 0.1 : 1})`,
width: px(58),
}}
>
{day}
</span>
);
})}
</div>
</div>
</div>
</Fill>
);
};
videos/components/date-picker/date-picker.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {DatePicker} from "./date-picker";
export default defineComponentPreview({
title: "Date picker",
category: "Interface/Controls",
description: "A calendar where a range fills outwards from the date chosen first.",
component: DatePicker,
canvas: {width: 1920, height: 1080, duration: "6s"},
controls: {
month: {type: "text", defaultValue: "August 2026", maxLength: 24},
picks: {type: "number", defaultValue: 12, min: 1, max: 28, step: 1},
through: {type: "number", defaultValue: 19, min: 0, max: 31, step: 1},
label: {type: "text", defaultValue: "Release window", maxLength: 28},
},
examples: [
{name: "A range", props: {}},
{name: "One date", props: {through: 0, label: "Ship date"}},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {DatePicker} from "../components/date-picker/date-picker";
<Video>
<Scene id="date-picker" duration="6s">
<DatePicker
month="August 2026"
picks={12}
through={19}
label="Release window"
/>
</Scene>
</Video>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Interface
- Recommended duration
- 180 frames · 6s
- Minimum duration
- 75 frames · 2.5s
- Entrance
- 14 frames
- Exit
- 10 frames
- Reduced motion
- range shown filled, no reach
- Requires
- sans font
- Content limits
- month ≤ 24, label ≤ 28
Props
| Prop | Type | Default | Required |
|---|---|---|---|
monthThe month heading. | string | "August 2026" | — |
startsOnWeekday of the first, 0 for Monday. | number | 5 | — |
daysHow many days the month has. | number | 31 | — |
picksThe day that gets picked. | number | 12 | — |
throughA second day, for a range. Zero for a single date. | number | 19 | — |
labelThe label above the calendar. | string | "Release window" | — |
openAt | number | 20 | — |
Related components
All componentsAccordionSections opening one at a time, the outgoing one closing as the next grows.Browser demoBrowser chrome for deterministic product interface frames.Card deckStacked cards that cycle from front to back.ComboboxA search field that filters a dropdown list.Command menuKeyboard-first action search and selection flow.Context menuThe menu that appears where you clicked, anchored to its own corner.