Calendar

Date And Time

Date and time in a single block: the month on the left, the column of free slots on the right, busy hours struck through and not clickable.

  • calendar
  • datetime
  • slots
  • booking

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-015?lang=en

март 2026 г.

пнвтсрчтптсбвс

Время

Запись на 18 марта, 15:30
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-015" (Date And Time) 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-015.json

Registry item: https://vibeui.ru/r/calendar-015.json
Installs to: components/vibeui/calendar-015.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
Date and time in a single block: the month on the left, the column of free slots on the right, busy hours struck through and not clickable.

Date and time picked in one block: a month and a slot column side by side, busy hours disabled, the chosen pair spelled out. Client-side, one file, zero dependencies.

## 3. How to use it
import { Calendar015 } from "@/components/vibeui/calendar-015"

<Calendar015
  defaultDate="2026-03-18"
  defaultTime="15:30"
  step={30}
  busy={["10:00", "13:00"]}
/>

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-015-* palette — with someone else's theme tokens the installed block will not match the preview
- flex-wrap with flex-basis instead of media queries: the layout must be computed from the block width, not the viewport
- the time column next to the calendar rather than as a second step: the user must see free hours before picking a day
- disabled with strike-through on busy slots: a hidden slot reads as "this time does not exist"
- the scrolling time column with max-height: at a fifteen-minute step the list would otherwise stretch the card across the screen
- the footer spelling out the chosen pair: after scrolling the slots the selection above is no longer visible

## 6. You may change
- the initial values through defaultDate and defaultTime
- the time step through step and the day bounds through opensAt and closesAt
- the busy hours through busy
- the label language through locale and the highlight colour through accent

## 7. Rules
- Busy hours are shared across days: in a real service busy has to be recomputed from outside when the date changes.
- The month grid shows five rows, not six: enough for picking a nearby date, but the tail of a long month may not fit.
- step is in minutes and should divide the working day evenly, otherwise the last slot ends up shorter than the rest.
- Time is stored as an "HH:MM" string without a time zone: assembling it into a Date is the calling code's job.

## 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-015.json
https://vibeui.ru/r/calendar-015.json

Клиентский компонент: useState держит выбранную дату и время. Две колонки лежат во flex с wrap, поэтому на узкой ширине время уходит под календарь, а не сжимается в нечитаемый столбик; ширины заданы через flex-basis, а не медиазапросами по вьюпорту. Сетка времени строится из opensAt, closesAt и шага step, а не задаётся списком: менять рабочие часы приходится чаще, чем сам компонент. Занятые слоты приходят массивом busy и выключаются атрибутом disabled с зачёркиванием. Колонка времени скроллится внутри себя с max-height, чтобы карточка не росла на весь экран при шаге в пятнадцать минут. Выбранная пара дата плюс время всегда показана в подвале словами — иначе после прокрутки не видно, что именно выбрано.

Component source

The same file your agent installs. Here in case you would rather copy it by hand.

"use client"

import { useState } from "react"
import type { ComponentPropsWithoutRef, CSSProperties } from "react"

export type Calendar015Props = Omit<
  ComponentPropsWithoutRef<"div">,
  "children" | "onChange" | "defaultValue"
> & {
  defaultDate?: string
  defaultTime?: string
  /** Шаг сетки времени в минутах: 15, 30 или 60. */
  step?: number
  opensAt?: string
  closesAt?: string
  busy?: string[]
  locale?: string
  onChange?: (value: { date: string; time: string }) => void
  accent?: string
}

// Идея компонента: дата и время выбираются в одном месте и в одном
// движении. Месяц слева, колонка времени справа — не всплывающий второй шаг:
// пользователь видит, что у выбранного дня осталось три свободных часа,
// до того как нажмёт на день.
const STYLES = `
:where([data-vibeui-block="calendar-015"]){
--vibeui-calendar-015-bg:oklch(1 0 0);
--vibeui-calendar-015-fg:oklch(0.24 0.014 265);
--vibeui-calendar-015-muted:oklch(0.62 0.014 265);
--vibeui-calendar-015-border:oklch(0.91 0.006 265);
--vibeui-calendar-015-hover:oklch(0.96 0.004 265);
--vibeui-calendar-015-accent:oklch(0.52 0.15 255);
--vibeui-calendar-015-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="calendar-015"]{
width:100%;max-width:29rem;box-sizing:border-box;padding:0.875rem;
background:var(--vibeui-calendar-015-bg);
border:1px solid var(--vibeui-calendar-015-border);border-radius:1rem;
color:var(--vibeui-calendar-015-fg);font-family:var(--vibeui-calendar-015-font);
}
[data-vibeui-block="calendar-015"] [data-part="shell"]{
display:flex;flex-wrap:wrap;gap:0.875rem;align-items:flex-start;
}
[data-vibeui-block="calendar-015"] [data-part="dates"]{flex:1 1 15rem;min-width:14rem}
[data-vibeui-block="calendar-015"] [data-part="times"]{
flex:0 1 7.5rem;min-width:6.5rem;
display:flex;flex-direction:column;gap:0.25rem;
max-height:15.5rem;overflow-y:auto;padding-right:0.125rem;
}
[data-vibeui-block="calendar-015"] [data-part="title"]{
margin:0 0 0.375rem;font-size:0.875rem;font-weight:650;
}
/* Заглавная только первая буква: capitalize поднимает и «г.» в «январь 2026 г.». */
[data-vibeui-block="calendar-015"] [data-part="title"]::first-letter{text-transform:uppercase}
[data-vibeui-block="calendar-015"] [data-part="legend"]{
margin:0 0 0.375rem;font-size:0.6875rem;font-weight:600;letter-spacing:0.04em;
text-transform:uppercase;color:var(--vibeui-calendar-015-muted);
}
[data-vibeui-block="calendar-015"] table{width:100%;border-collapse:collapse;table-layout:fixed}
[data-vibeui-block="calendar-015"] th{
padding:0.1875rem 0;font-size:0.6875rem;font-weight:600;
color:var(--vibeui-calendar-015-muted);text-transform:capitalize;
}
[data-vibeui-block="calendar-015"] td{padding:0.0625rem;text-align:center}
[data-vibeui-block="calendar-015"] td button{
appearance:none;cursor:pointer;
display:inline-flex;align-items:center;justify-content:center;
width:1.875rem;height:1.875rem;padding:0;border:0;border-radius:0.5rem;
background:transparent;color:inherit;
font:inherit;font-size:0.8125rem;font-variant-numeric:tabular-nums;
}
[data-vibeui-block="calendar-015"] td button:hover{background:var(--vibeui-calendar-015-hover)}
[data-vibeui-block="calendar-015"] td button:focus-visible{outline:2px solid var(--vibeui-calendar-015-accent);outline-offset:-2px}
[data-vibeui-block="calendar-015"] td button[data-outside="true"]{color:var(--vibeui-calendar-015-muted);opacity:.5}
[data-vibeui-block="calendar-015"] td button[aria-pressed="true"]{
background:var(--vibeui-calendar-015-accent);color:oklch(0.99 0.01 255);font-weight:650;opacity:1;
}
[data-vibeui-block="calendar-015"] [data-part="times"] button{
appearance:none;cursor:pointer;flex:none;
height:2rem;padding:0 0.5rem;border-radius:0.5rem;
border:1px solid var(--vibeui-calendar-015-border);
background:transparent;color:inherit;
font:inherit;font-size:0.8125rem;font-variant-numeric:tabular-nums;
}
[data-vibeui-block="calendar-015"] [data-part="times"] button:hover:not(:disabled){background:var(--vibeui-calendar-015-hover)}
[data-vibeui-block="calendar-015"] [data-part="times"] button:focus-visible{outline:2px solid var(--vibeui-calendar-015-accent);outline-offset:2px}
[data-vibeui-block="calendar-015"] [data-part="times"] button:disabled{
cursor:not-allowed;color:var(--vibeui-calendar-015-muted);opacity:.5;text-decoration:line-through;
}
[data-vibeui-block="calendar-015"] [data-part="times"] button[aria-pressed="true"]{
background:var(--vibeui-calendar-015-accent);color:oklch(0.99 0.01 255);
border-color:var(--vibeui-calendar-015-accent);font-weight:650;
}
[data-vibeui-block="calendar-015"] [data-part="foot"]{
display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:0.5rem;
margin-top:0.875rem;padding-top:0.75rem;border-top:1px solid var(--vibeui-calendar-015-border);
font-size:0.8125rem;
}
[data-vibeui-block="calendar-015"] [data-part="summary"]{color:var(--vibeui-calendar-015-muted)}
[data-vibeui-block="calendar-015"] [data-part="summary"] strong{color:var(--vibeui-calendar-015-fg)}
[data-vibeui-block="calendar-015"] [data-part="submit"]{
appearance:none;cursor:pointer;border:0;border-radius:0.5rem;
height:2.25rem;padding:0 1rem;
background:var(--vibeui-calendar-015-accent);color:oklch(0.99 0.01 255);
font:inherit;font-size:0.8125rem;font-weight:650;
}
[data-vibeui-block="calendar-015"] [data-part="submit"]:focus-visible{outline:2px solid var(--vibeui-calendar-015-accent);outline-offset:2px}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="calendar-015"] *{animation:none!important;transition:none!important}}
`

const DAY = 86400000

function iso(date: Date) {
  return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`
}

function minutes(value: string) {
  const [hour, minute] = value.split(":").map(Number)
  return hour * 60 + minute
}

function clock(value: number) {
  return `${String(Math.floor(value / 60)).padStart(2, "0")}:${String(value % 60).padStart(2, "0")}`
}

const DEFAULT_BUSY = ["10:00", "10:30", "13:00", "16:30"]

/**
 * Дата и время в одном блоке: месяц слева, сетка времени справа.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Calendar015({
  defaultDate = "2026-03-18",
  defaultTime = "15:30",
  step = 30,
  opensAt = "09:00",
  closesAt = "18:00",
  busy = DEFAULT_BUSY,
  locale = "ru-RU",
  onChange,
  accent,
  className,
  style,
  ...props
}: Calendar015Props) {
  const [date, setDate] = useState(defaultDate)
  const [time, setTime] = useState(defaultTime)

  const [year, month] = defaultDate.split("-").map(Number)
  const first = new Date(year, month - 1, 1)
  const start = new Date(first.getTime() - ((first.getDay() + 6) % 7) * DAY)
  const cells = Array.from(
    { length: 35 },
    (_, index) => new Date(start.getTime() + index * DAY),
  )

  const from = minutes(opensAt)
  const to = minutes(closesAt)
  const slots = Array.from(
    { length: Math.max(1, Math.floor((to - from) / step)) },
    (_, index) => clock(from + index * step),
  )

  const weekday = new Intl.DateTimeFormat(locale, { weekday: "short" })
  const long = new Intl.DateTimeFormat(locale, {
    day: "numeric",
    month: "long",
  })
  const title = new Intl.DateTimeFormat(locale, {
    month: "long",
    year: "numeric",
  }).format(first)

  const palette = {
    ...(accent ? { "--vibeui-calendar-015-accent": accent } : null),
    ...style,
  } as CSSProperties

  const pickDate = (value: string) => {
    setDate(value)
    onChange?.({ date: value, time })
  }

  const pickTime = (value: string) => {
    setTime(value)
    onChange?.({ date, time: value })
  }

  return (
    <>
      <style href="vibeui-calendar-015" precedence="medium">
        {STYLES}
      </style>
      <div
        {...props}
        data-vibeui-block="calendar-015"
        className={className}
        style={palette}
      >
        <div data-part="shell">
          <div data-part="dates">
            <p data-part="title">{title}</p>
            <table>
              <thead>
                <tr>
                  {cells.slice(0, 7).map((day) => (
                    <th key={iso(day)} scope="col">
                      {weekday.format(day)}
                    </th>
                  ))}
                </tr>
              </thead>
              <tbody>
                {Array.from({ length: 5 }, (_, row) => (
                  <tr key={row}>
                    {cells.slice(row * 7, row * 7 + 7).map((day) => {
                      const value = iso(day)

                      return (
                        <td key={value}>
                          <button
                            type="button"
                            aria-pressed={value === date}
                            aria-label={long.format(day)}
                            data-outside={day.getMonth() !== month - 1}
                            onClick={() => pickDate(value)}
                          >
                            {day.getDate()}
                          </button>
                        </td>
                      )
                    })}
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
          <div data-part="times" role="group" aria-label="Время приёма">
            <p data-part="legend">Время</p>
            {slots.map((slot) => (
              <button
                key={slot}
                type="button"
                aria-pressed={slot === time}
                disabled={busy.includes(slot)}
                onClick={() => pickTime(slot)}
              >
                {slot}
              </button>
            ))}
          </div>
        </div>
        <div data-part="foot">
          <span data-part="summary">
            Запись на{" "}
            <strong>
              {long.format(new Date(`${date}T00:00:00`))}, {time}
            </strong>
          </span>
          <button type="button" data-part="submit">
            Подтвердить
          </button>
        </div>
      </div>
    </>
  )
}