Calendar

Price Calendar

A booking calendar with the nightly price inside every cell: the cheapest days are outlined, sold-out ones struck through, and the total sits at the bottom.

  • calendar
  • booking
  • price
  • availability

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

апрель 2026 г.

от 3 900 за ночь

Заезд 14 апреля 2026 г., 2 ночи7 800
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-019" (Price Calendar) 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-019.json

Registry item: https://vibeui.ru/r/calendar-019.json
Installs to: components/vibeui/calendar-019.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 booking calendar with the nightly price inside every cell: the cheapest days are outlined, sold-out ones struck through, and the total sits at the bottom.

A booking calendar: a nightly price in every cell, the month's minimum outlined, sold-out days disabled and a stay total in the footer. Client-side, one file, zero dependencies.

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

<Calendar019
  year={2026}
  month={4}
  basePrice={4200}
  nights={2}
  currency="$"
/>

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-019-* palette — with someone else's theme tokens the block will not match the preview
- the deterministic price calculation: a random number would split server and client render and break hydration
- the price inside the cell rather than after selection: "when is it cheaper" is asked before the click
- the outline plus label colour on the minimum price: a single hue cannot mark a cheap day on its own
- sold-out days kept in place with a strike-through: a missing day reads as a loading failure
- the stay total in the footer: the user should not multiply price by nights in their head

## 6. You may change
- the month and the year through month and year
- the base price through basePrice and the stay length through nights
- the currency sign through currency and the sold-out days through soldOut
- the language and number format through locale, the highlight colour through accent

## 7. Rules
- The prices here are a demo: in production the pricing function is replaced by a server array, and the cell shape stays the same.
- The minimum is searched among available days only, so a cheap sold-out day is never highlighted.
- nights multiplies the selected day's price instead of summing consecutive nights — an exact total needs a range.
- Below twenty rem the in-cell price becomes tiny: under that limit a list serves better than a grid.

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

Клиентский компонент: useState держит выбранный день заезда. Цена считается детерминированной функцией от числа месяца и дня недели, а не случайно, — случайность развела бы серверный и клиентский рендер. Цена стоит в клетке, а не открывается после выбора: календарь бронирования отвечает на вопрос «когда дешевле», и ради этого клетка сделана выше обычной. Минимальная цена месяца вычисляется по доступным дням и помечается обводкой и цветом подписи, а не только цветом. Занятые дни выключены атрибутом disabled и перечёркнуты, но остаются в сетке: пропавший день читается как ошибка загрузки. Подвал показывает итог за выбранное число ночей — умножение в уме тут лишнее.

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 Calendar019Props = Omit<
  ComponentPropsWithoutRef<"div">,
  "children" | "onChange" | "defaultValue"
> & {
  year?: number
  month?: number
  /** Базовая цена ночи: выходные и волна спроса считаются от неё. */
  basePrice?: number
  nights?: number
  currency?: string
  soldOut?: number[]
  locale?: string
  onChange?: (iso: string) => void
  accent?: string
}

// Идея компонента: цена стоит в клетке, а не открывается после выбора.
// Календарь бронирования отвечает на вопрос «когда дешевле», поэтому самые
// дешёвые дни помечены отдельно, а занятые показывают перечёркнутую клетку,
// а не исчезают: пропавший день читается как ошибка загрузки.
const STYLES = `
:where([data-vibeui-block="calendar-019"]){
--vibeui-calendar-019-bg:oklch(1 0 0);
--vibeui-calendar-019-fg:oklch(0.24 0.014 265);
--vibeui-calendar-019-muted:oklch(0.62 0.014 265);
--vibeui-calendar-019-border:oklch(0.91 0.006 265);
--vibeui-calendar-019-hover:oklch(0.96 0.004 265);
--vibeui-calendar-019-accent:oklch(0.5 0.14 175);
--vibeui-calendar-019-cheap:oklch(0.52 0.15 145);
--vibeui-calendar-019-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="calendar-019"]{
display:flex;flex-direction:column;gap:0.75rem;
width:100%;max-width:26rem;box-sizing:border-box;padding:1rem;
background:var(--vibeui-calendar-019-bg);
border:1px solid var(--vibeui-calendar-019-border);border-radius:1rem;
color:var(--vibeui-calendar-019-fg);font-family:var(--vibeui-calendar-019-font);
}
[data-vibeui-block="calendar-019"] [data-part="head"]{
display:flex;flex-wrap:wrap;align-items:baseline;justify-content:space-between;gap:0.5rem;
}
[data-vibeui-block="calendar-019"] [data-part="title"]{
margin:0;font-size:0.9375rem;font-weight:700;letter-spacing:-0.01em;
}
/* Заглавная только первая буква: capitalize поднимает и «г.» в «январь 2026 г.». */
[data-vibeui-block="calendar-019"] [data-part="title"]::first-letter{text-transform:uppercase}
[data-vibeui-block="calendar-019"] [data-part="from"]{
font-size:0.75rem;color:var(--vibeui-calendar-019-muted);
}
[data-vibeui-block="calendar-019"] [data-part="from"] b{color:var(--vibeui-calendar-019-cheap);font-variant-numeric:tabular-nums}
[data-vibeui-block="calendar-019"] [data-part="grid"]{
display:grid;grid-template-columns:repeat(7,1fr);gap:0.1875rem;
}
[data-vibeui-block="calendar-019"] [data-part="wd"]{
padding-bottom:0.125rem;text-align:center;
font-size:0.6875rem;font-weight:600;text-transform:capitalize;
color:var(--vibeui-calendar-019-muted);
}
[data-vibeui-block="calendar-019"] [data-part="grid"] button{
appearance:none;cursor:pointer;
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:0.0625rem;
min-height:3rem;padding:0.25rem 0.125rem;border-radius:0.5rem;
border:1px solid transparent;background:transparent;color:inherit;font:inherit;
}
[data-vibeui-block="calendar-019"] [data-part="grid"] button:hover:not(:disabled){background:var(--vibeui-calendar-019-hover)}
[data-vibeui-block="calendar-019"] [data-part="grid"] button:focus-visible{outline:2px solid var(--vibeui-calendar-019-accent);outline-offset:-2px}
[data-vibeui-block="calendar-019"] [data-part="day"]{font-size:0.8125rem;line-height:1;font-variant-numeric:tabular-nums}
[data-vibeui-block="calendar-019"] [data-part="price"]{
font-size:0.625rem;line-height:1.1;font-variant-numeric:tabular-nums;
color:var(--vibeui-calendar-019-muted);
}
[data-vibeui-block="calendar-019"] [data-part="grid"] button[data-cheap="true"] [data-part="price"]{
color:var(--vibeui-calendar-019-cheap);font-weight:700;
}
[data-vibeui-block="calendar-019"] [data-part="grid"] button[data-cheap="true"]{border-color:color-mix(in oklab,var(--vibeui-calendar-019-cheap) 45%,transparent)}
[data-vibeui-block="calendar-019"] [data-part="grid"] button:disabled{
cursor:not-allowed;color:var(--vibeui-calendar-019-muted);opacity:.45;
}
[data-vibeui-block="calendar-019"] [data-part="grid"] button:disabled [data-part="day"]{text-decoration:line-through}
[data-vibeui-block="calendar-019"] [data-part="grid"] button[aria-pressed="true"]{
background:var(--vibeui-calendar-019-accent);border-color:var(--vibeui-calendar-019-accent);
color:oklch(0.99 0.01 175);
}
[data-vibeui-block="calendar-019"] [data-part="grid"] button[aria-pressed="true"] [data-part="price"]{color:oklch(0.99 0.01 175);font-weight:650}
[data-vibeui-block="calendar-019"] [data-part="total"]{
display:flex;flex-wrap:wrap;align-items:baseline;justify-content:space-between;gap:0.5rem;
padding-top:0.75rem;border-top:1px solid var(--vibeui-calendar-019-border);
font-size:0.8125rem;color:var(--vibeui-calendar-019-muted);
}
[data-vibeui-block="calendar-019"] [data-part="sum"]{
font-size:1.125rem;font-weight:700;color:var(--vibeui-calendar-019-fg);
font-variant-numeric:tabular-nums;letter-spacing:-0.01em;
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="calendar-019"] *{animation:none!important;transition:none!important}}
`

function priceOf(base: number, date: Date) {
  const weekday = date.getDay()
  const bump = weekday === 5 || weekday === 6 ? 1.35 : weekday === 0 ? 1.15 : 1
  // Волна спроса детерминирована числом месяца: случайность сломала бы
  // совпадение серверного и клиентского рендера.
  const wave = ((date.getDate() * 37) % 11) - 5

  return Math.round((base * bump + wave * 80) / 50) * 50
}

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 Calendar019({
  year = 2026,
  month = 4,
  basePrice = 4200,
  nights = 2,
  currency = "₽",
  soldOut = [9, 10, 22],
  locale = "ru-RU",
  onChange,
  accent,
  className,
  style,
  ...props
}: Calendar019Props) {
  const first = new Date(year, month - 1, 1)
  const lead = (first.getDay() + 6) % 7
  const length = new Date(year, month, 0).getDate()

  const days = Array.from({ length }, (_, index) => {
    const date = new Date(year, month - 1, index + 1)

    return {
      day: index + 1,
      date,
      price: priceOf(basePrice, date),
      sold: soldOut.includes(index + 1),
    }
  })

  const cheapest = Math.min(
    ...days.filter((entry) => !entry.sold).map((entry) => entry.price),
  )

  const [selected, setSelected] = useState(
    () =>
      days.find((entry) => !entry.sold && entry.price === cheapest)?.day ?? 1,
  )

  const money = new Intl.NumberFormat(locale, { maximumFractionDigits: 0 })
  const weekdayName = new Intl.DateTimeFormat(locale, { weekday: "short" })
  const weekdays = Array.from({ length: 7 }, (_, index) =>
    weekdayName.format(new Date(2026, 0, 5 + index)),
  )
  const title = new Intl.DateTimeFormat(locale, {
    month: "long",
    year: "numeric",
  }).format(first)
  const long = new Intl.DateTimeFormat(locale, { dateStyle: "long" })

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

  const current = days.find((entry) => entry.day === selected) ?? days[0]
  const total = current.price * nights

  const pick = (day: number) => {
    setSelected(day)
    onChange?.(
      `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`,
    )
  }

  return (
    <>
      <style href="vibeui-calendar-019" precedence="medium">
        {STYLES}
      </style>
      <div
        {...props}
        data-vibeui-block="calendar-019"
        className={className}
        style={palette}
      >
        <div data-part="head">
          <p data-part="title">{title}</p>
          <p data-part="from">
            от{" "}
            <b>
              {money.format(cheapest)} {currency}
            </b>{" "}
            за ночь
          </p>
        </div>
        <div data-part="grid" role="group" aria-label="Даты заезда и цены">
          {weekdays.map((label) => (
            <span key={label} data-part="wd" aria-hidden="true">
              {label}
            </span>
          ))}
          {Array.from({ length: lead }, (_, index) => (
            <span key={`lead-${index}`} />
          ))}
          {days.map((entry) => (
            <button
              key={entry.day}
              type="button"
              aria-pressed={entry.day === selected}
              disabled={entry.sold}
              data-cheap={!entry.sold && entry.price === cheapest}
              aria-label={
                entry.sold
                  ? `${long.format(entry.date)} — мест нет`
                  : `${long.format(entry.date)} — ${money.format(entry.price)} ${currency} за ночь`
              }
              onClick={() => pick(entry.day)}
            >
              <span data-part="day">{entry.day}</span>
              <span data-part="price">
                {entry.sold ? "нет" : money.format(entry.price)}
              </span>
            </button>
          ))}
        </div>
        <div data-part="total">
          <span>
            Заезд {long.format(current.date)}, {nights}{" "}
            {pluralize(nights, ["ночь", "ночи", "ночей"])}
          </span>
          <span data-part="sum">
            {money.format(total)} {currency}
          </span>
        </div>
      </div>
    </>
  )
}