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

1440pxHost theme

Use it with AI

  1. 1. Copy the link.
  2. 2. Write to your agent in your own words and drop the link into the sentence.
  3. 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

Date of birth

31 год

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.json
https://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>
    </>
  )
}