Calendar
Birth Date Fields
A date of birth as three fields instead of a month grid: day, month and year typed from the keyboard, with validation that separates an impossible day, a future date and an age limit.
- calendar
- birthday
- form
- validation
Preview
Use it with AI
- 1. Copy the link.
- 2. Write to your agent in your own words and drop the link into the sentence.
- 3. The agent opens the link and installs the component from the registry.
put this in the header: https://vibeui.ru/c/calendar-024?lang=en
Show the full instructions
What the link says, spelled out. Use it when your agent cannot open links — paste this text instead.
# Install and place "calendar-024" (Birth Date Fields) from VibeUI
## 1. Install first — do not skip, do not recreate
Run this exact command before writing any code:
npx shadcn@latest add https://vibeui.ru/r/calendar-024.json
Registry item: https://vibeui.ru/r/calendar-024.json
Installs to: components/vibeui/calendar-024.tsx (the exact path follows this project's components.json aliases).
npm dependencies: none.
Registry dependencies: none.
Install it from the registry. Do not recreate it from the description,
do not substitute a similar component from another library, and do not
rewrite it to match the project's existing style.
## 2. What it is
A date of birth as three fields instead of a month grid: day, month and year typed from the keyboard, with validation that separates an impossible day, a future date and an age limit.
Date-of-birth entry as three fields: day, a month dropdown and year. A month grid needs twenty “back” taps to reach 1985, while this takes a second to type. Validation splits into readable cases: 31 February, a date in the future, an age below the limit, an implausible year. Zero dependencies, one file, its own palette.
## 3. How to use it
import { Calendar024 } from "@/components/vibeui/calendar-024"
<Calendar024 minAge={18} defaultValue="1994-07-19" />
Read the installed file for the full prop list.
## 4. Where to place it
This is a small inline component. Put it exactly where the user asked,
inside the existing markup. Do not create a new page, section or
wrapper for it. If a similar control already sits in that spot,
replace it instead of adding a second one.
Placement: ___
(The user fills this line in. If it is still blank, ask where to put it
instead of guessing.)
## 5. Keep exactly as installed
- the local --vibeui-calendar-024-* palette — do not swap it for your theme tokens (border-input, text-destructive and the like)
- three separate fields instead of a calendar: picking a birth year in a month grid is dozens of taps
- the autoComplete attributes bday-day, bday-month and bday-year: without them the browser will not fill a saved date
- the month as a list of names rather than a number: 03/04 reads differently in different countries
- splitting errors into cases instead of one “invalid date” — a person has to know what exactly to fix
- aria-invalid and aria-describedby on all three fields, tied to the single explanation line
- the note with aria-live="polite" and its status dot: an error must not exist only as a red border
## 6. You may change
- label — the heading of the field group
- defaultValue — the initial date as a YYYY-MM-DD string, or empty
- minAge — the minimum age below which the form complains
- maxAge — the upper plausibility bound for an age
- today — the reference date the age is measured from; locale — the locale of month names
- onChange — the handler for the assembled date; accent — the focus colour
## 7. Rules
- Do not compute age by dividing a millisecond difference by 365 days: leap years put you a day off, and on the limit boundary that matters.
- Do not read the current date from Date.now() inside the component: server and client renders would diverge, which is why “today” arrives as a prop.
- Do not block digits in the day field: people type faster than they think, and corrections are easier after typing than during it.
- The age limit is not the only check: a two-digit year (“94”) has to be caught too, or you get the year 94 AD.
## 8. Verify
- it renders with no console errors;
- it looks like the preview on the VibeUI page you copied this from;
- if it does not, you changed something listed in section 5 — put it back.For developers
npx shadcn@latest add https://vibeui.ru/r/calendar-024.jsonhttps://vibeui.ru/r/calendar-024.jsonКомпонент самодостаточен: один файл, ноль зависимостей, собственная палитра в локальных переменных --vibeui-calendar-024-*. Клиентский: три части даты живут в отдельных useState, наружу onChange отдаёт собранную строку YYYY-MM-DD или пустую строку, если дата неполная. Корень — <fieldset> с <legend>, у каждого поля своя <label>, а автозаполнение включено атрибутами autoComplete="bday-day|bday-month|bday-year". Месяц сделан <select> с названиями из Intl, поэтому цифра 07 не спорит с форматом даты в стране пользователя. Проверка собрана в один useMemo и возвращает не булево, а состояние idle/ok/bad с текстом: количество дней в месяце считается через new Date(year, month, 0).getDate(), возраст — сравнением месяца и дня. Состояние поднимается на корень как data-state, поэтому рамки полей и цвет подписи красятся одним правилом.
Component source
The same file your agent installs. Here in case you would rather copy it by hand.
"use client"
import { useId, useMemo, useState } from "react"
import type { ComponentPropsWithoutRef, CSSProperties } from "react"
export type Calendar024Props = Omit<
ComponentPropsWithoutRef<"fieldset">,
"children" | "onChange"
> & {
label?: string
/** Начальное значение строкой YYYY-MM-DD или пустая строка. */
defaultValue?: string
minAge?: number
maxAge?: number
today?: string
locale?: string
onChange?: (value: string) => void
accent?: string
}
// Идея компонента: дату рождения выбирают не в сетке месяца — до 1985 года
// из неё листать двадцать раз. Три поля вводятся с клавиатуры за секунду,
// а вся сложность уезжает в проверку: 31 февраля, будущее и возрастной ценз
// разбираются отдельными сообщениями, а не одним «неверная дата».
const STYLES = `
:where([data-vibeui-block="calendar-024"]){
--vibeui-calendar-024-bg:oklch(1 0 0);
--vibeui-calendar-024-fg:oklch(0.23 0.014 275);
--vibeui-calendar-024-muted:oklch(0.56 0.014 275);
--vibeui-calendar-024-border:oklch(0.9 0.008 275);
--vibeui-calendar-024-field:oklch(0.985 0.004 275);
--vibeui-calendar-024-accent:oklch(0.51 0.13 275);
--vibeui-calendar-024-ok:oklch(0.5 0.11 155);
--vibeui-calendar-024-bad:oklch(0.55 0.18 25);
--vibeui-calendar-024-radius:0.625rem;
--vibeui-calendar-024-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="calendar-024"]{
display:flex;flex-direction:column;gap:0.6rem;
width:100%;max-width:23rem;box-sizing:border-box;
margin:0;padding:1rem;
background:var(--vibeui-calendar-024-bg);
border:1px solid var(--vibeui-calendar-024-border);
border-radius:calc(var(--vibeui-calendar-024-radius) + 0.25rem);
color:var(--vibeui-calendar-024-fg);
font-family:var(--vibeui-calendar-024-font);
}
[data-vibeui-block="calendar-024"] legend{
padding:0;font-size:0.9rem;font-weight:700;letter-spacing:-0.01em;
}
[data-vibeui-block="calendar-024"] [data-part="row"]{
display:grid;grid-template-columns:4.5rem 1fr 5.5rem;gap:0.4rem;align-items:end;
}
[data-vibeui-block="calendar-024"] [data-part="cell"]{
display:flex;flex-direction:column;gap:0.2rem;min-width:0;
}
[data-vibeui-block="calendar-024"] [data-part="cell"] label{
font-size:0.7rem;font-weight:600;letter-spacing:0.03em;text-transform:uppercase;
color:var(--vibeui-calendar-024-muted);
}
[data-vibeui-block="calendar-024"] input,
[data-vibeui-block="calendar-024"] select{
box-sizing:border-box;width:100%;height:2.4rem;padding:0 0.55rem;
border:1px solid var(--vibeui-calendar-024-border);
border-radius:var(--vibeui-calendar-024-radius);
background:var(--vibeui-calendar-024-field);
color:inherit;font:inherit;font-size:0.875rem;
font-variant-numeric:tabular-nums;
}
[data-vibeui-block="calendar-024"] select{text-transform:capitalize}
[data-vibeui-block="calendar-024"] input:focus-visible,
[data-vibeui-block="calendar-024"] select:focus-visible{
outline:2px solid var(--vibeui-calendar-024-accent);outline-offset:1px;border-color:transparent;
}
[data-vibeui-block="calendar-024"][data-state="bad"] input,
[data-vibeui-block="calendar-024"][data-state="bad"] select{
border-color:var(--vibeui-calendar-024-bad);
}
[data-vibeui-block="calendar-024"] [data-part="note"]{
margin:0;display:flex;align-items:center;gap:0.35rem;
min-height:1.15rem;font-size:0.8125rem;color:var(--vibeui-calendar-024-muted);
}
[data-vibeui-block="calendar-024"][data-state="bad"] [data-part="note"]{color:var(--vibeui-calendar-024-bad)}
[data-vibeui-block="calendar-024"][data-state="ok"] [data-part="note"]{color:var(--vibeui-calendar-024-ok)}
[data-vibeui-block="calendar-024"] [data-part="dot"]{
width:0.45rem;height:0.45rem;border-radius:50%;flex:none;background:currentColor;
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="calendar-024"] *{animation:none!important;transition:none!important}}
`
function daysInMonth(year: number, month: number) {
return new Date(year, month, 0).getDate()
}
function pluralize(count: number, forms: [string, string, string]) {
const tens = count % 100
const ones = count % 10
if (tens > 10 && tens < 20) return forms[2]
if (ones === 1) return forms[0]
if (ones > 1 && ones < 5) return forms[1]
return forms[2]
}
/**
* Дата рождения тремя полями с разбором ошибок и подсчётом возраста.
* Один файл, ноль зависимостей, собственная палитра.
*/
export function Calendar024({
label = "Дата рождения",
defaultValue = "1994-07-19",
minAge = 18,
maxAge = 120,
today = "2026-04-15",
locale = "ru-RU",
onChange,
accent,
className,
style,
...props
}: Calendar024Props) {
const id = useId()
const parts = defaultValue.split("-")
const [year, setYear] = useState(parts[0] ?? "")
const [month, setMonth] = useState(parts[1] ?? "")
const [day, setDay] = useState(parts[2] ?? "")
const months = useMemo(() => {
const format = new Intl.DateTimeFormat(locale, { month: "long" })
return Array.from({ length: 12 }, (_, index) => ({
value: String(index + 1).padStart(2, "0"),
title: format.format(new Date(2024, index, 1)),
}))
}, [locale])
const check = useMemo(() => {
if (!year || !month || !day) {
return { state: "idle", note: "Введите день, месяц и год" }
}
const numbers = {
year: Number(year),
month: Number(month),
day: Number(day),
}
if (numbers.year < 1000) {
return { state: "idle", note: "Год из четырёх цифр" }
}
if (
numbers.day < 1 ||
numbers.day > daysInMonth(numbers.year, numbers.month)
) {
return {
state: "bad",
note: `В этом месяце ${daysInMonth(numbers.year, numbers.month)} дней`,
}
}
const born = new Date(numbers.year, numbers.month - 1, numbers.day)
const now = new Date(`${today}T00:00:00`)
if (born.getTime() > now.getTime()) {
return { state: "bad", note: "Дата в будущем" }
}
let age = now.getFullYear() - born.getFullYear()
const passed =
now.getMonth() > born.getMonth() ||
(now.getMonth() === born.getMonth() && now.getDate() >= born.getDate())
if (!passed) age -= 1
if (age < minAge) {
return { state: "bad", note: `Нужно не меньше ${minAge} лет` }
}
if (age > maxAge) {
return { state: "bad", note: "Проверьте год: возраст слишком большой" }
}
return {
state: "ok",
note: `${age} ${pluralize(age, ["год", "года", "лет"])}`,
}
}, [year, month, day, today, minAge, maxAge])
const push = (next: { year: string; month: string; day: string }) => {
onChange?.(
next.year && next.month && next.day
? `${next.year}-${next.month.padStart(2, "0")}-${next.day.padStart(2, "0")}`
: "",
)
}
const palette = {
...(accent ? { "--vibeui-calendar-024-accent": accent } : null),
...style,
} as CSSProperties
return (
<>
<style href="vibeui-calendar-024" precedence="medium">
{STYLES}
</style>
<fieldset
{...props}
data-vibeui-block="calendar-024"
data-state={check.state}
className={className}
style={palette}
>
<legend>{label}</legend>
<div data-part="row">
<div data-part="cell">
<label htmlFor={`${id}-day`}>День</label>
<input
id={`${id}-day`}
inputMode="numeric"
autoComplete="bday-day"
maxLength={2}
aria-invalid={check.state === "bad"}
aria-describedby={`${id}-note`}
value={day}
onChange={(event) => {
const next = event.target.value.replace(/\D/g, "").slice(0, 2)
setDay(next)
push({ year, month, day: next })
}}
/>
</div>
<div data-part="cell">
<label htmlFor={`${id}-month`}>Месяц</label>
<select
id={`${id}-month`}
autoComplete="bday-month"
aria-invalid={check.state === "bad"}
aria-describedby={`${id}-note`}
value={month}
onChange={(event) => {
const next = event.target.value
setMonth(next)
push({ year, month: next, day })
}}
>
<option value="">—</option>
{months.map((entry) => (
<option key={entry.value} value={entry.value}>
{entry.title}
</option>
))}
</select>
</div>
<div data-part="cell">
<label htmlFor={`${id}-year`}>Год</label>
<input
id={`${id}-year`}
inputMode="numeric"
autoComplete="bday-year"
maxLength={4}
aria-invalid={check.state === "bad"}
aria-describedby={`${id}-note`}
value={year}
onChange={(event) => {
const next = event.target.value.replace(/\D/g, "").slice(0, 4)
setYear(next)
push({ year: next, month, day })
}}
/>
</div>
</div>
<p id={`${id}-note`} data-part="note" aria-live="polite">
<span data-part="dot" aria-hidden="true" />
{check.note}
</p>
</fieldset>
</>
)
}