Calendar
Capped Range
A range with a hard cap of N days: once the first date is picked the unreachable days go dim, and the counter below shows how much of the limit is left.
- calendar
- range
- limit
- booking
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-030?lang=en
апрель 2026 г.
Не больше 14 дней подряд
Выбрано 7 из 14 дней
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-030" (Capped Range) 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-030.json
Registry item: https://vibeui.ru/r/calendar-030.json
Installs to: components/vibeui/calendar-030.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 range with a hard cap of N days: once the first date is picked the unreachable days go dim, and the counter below shows how much of the limit is left.
A date-range picker with a hard length limit: a rental of no more than two weeks, leave of no more than 28 days, a report no deeper than a month. The rule is enforced by the shape of the grid rather than by an error after submit — after the first date the extra days physically go dim, while the bar and counter show the remaining allowance. Zero dependencies, one file, its own palette.
## 3. How to use it
import { Calendar030 } from "@/components/vibeui/calendar-030"
<Calendar030 maxDays={14} month="2026-04" />
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-030-* palette — do not swap it for your theme tokens (bg-accent, text-muted-foreground and the like)
- expressing the cap as grid state rather than a post-submit error: the restriction has to be visible before the click
- the native disabled attribute on unreachable days — it removes them from the tab order and is announced by screen readers
- the intermediate “only the start is picked” state with its hint about the remaining days
- the inclusive length count (end minus start plus one): 6 to 12 is seven days
- the fill bar driven by --vibeui-calendar-030-fill: it shows the limit running out without reading numbers
- the summary with aria-live="polite": dimming days is invisible to a screen reader and the reason must be spoken
## 6. You may change
- month — the displayed month as a YYYY-MM string
- maxDays — the maximum range length in days
- defaultFrom and defaultTo — the bounds selected at first render
- locale — the locale of the month heading and weekday names
- onChange — the handler for both bounds; an empty second one means the pick is unfinished
- accent — the colour of the range edges and of the fill bar
## 7. Rules
- One month is shown: with a limit above 20 days the range stops fitting and a two-month variant is needed.
- The reset button is mandatory: without it the “start picked” state cannot be left without picking an end.
- Do not replace disabled with a dimmed colour: the keyboard would walk onto blocked days and select a forbidden one.
- The cap here counts calendar days; working days need a different calculation and different wording.
## 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-030.jsonhttps://vibeui.ru/r/calendar-030.jsonКомпонент самодостаточен: один файл, ноль зависимостей, собственная палитра в локальных переменных --vibeui-calendar-030-*. Клиентский: обе границы диапазона живут в useState. Ограничение выражено не сообщением об ошибке, а состоянием сетки: пока выбрана только первая дата, функция blocked гасит дни раньше начала и дальше maxDays-1 через нативный disabled, поэтому запрет объявляется и скринридеру. Число строк сетки считается по Math.ceil((offset+дней)/7)*7, чтобы месяц не оставлял пустую шестую неделю. Полоса прогресса под сеткой заполняется переменной --vibeui-calendar-030-fill из отношения длины диапазона к лимиту. Итог склоняется через pluralize и объявляется через aria-live.
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 Calendar030Props = Omit<
ComponentPropsWithoutRef<"section">,
"children" | "onChange"
> & {
month?: string
maxDays?: number
defaultFrom?: string
defaultTo?: string
locale?: string
onChange?: (from: string, to: string) => void
accent?: string
}
// Идея компонента: ограничение «не больше N дней» нельзя объяснять ошибкой
// после отправки формы. Здесь после выбора первой даты недоступные дни
// физически гаснут: правило видно как форма сетки, а счётчик снизу говорит,
// сколько дней ещё осталось в лимите.
const STYLES = `
:where([data-vibeui-block="calendar-030"]){
--vibeui-calendar-030-bg:oklch(1 0 0);
--vibeui-calendar-030-fg:oklch(0.23 0.014 165);
--vibeui-calendar-030-muted:oklch(0.57 0.014 165);
--vibeui-calendar-030-faint:oklch(0.82 0.01 165);
--vibeui-calendar-030-border:oklch(0.91 0.008 165);
--vibeui-calendar-030-soft:oklch(0.97 0.008 165);
--vibeui-calendar-030-accent:oklch(0.48 0.11 165);
--vibeui-calendar-030-accentsoft:oklch(0.94 0.05 165);
--vibeui-calendar-030-radius:0.75rem;
--vibeui-calendar-030-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="calendar-030"]{
display:flex;flex-direction:column;gap:0.7rem;
width:100%;max-width:22rem;box-sizing:border-box;padding:1rem;
background:var(--vibeui-calendar-030-bg);
border:1px solid var(--vibeui-calendar-030-border);
border-radius:calc(var(--vibeui-calendar-030-radius) + 0.25rem);
color:var(--vibeui-calendar-030-fg);
font-family:var(--vibeui-calendar-030-font);
}
[data-vibeui-block="calendar-030"] [data-part="title"]{
margin:0;font-size:0.95rem;font-weight:700;letter-spacing:-0.01em;text-transform:capitalize;
}
[data-vibeui-block="calendar-030"] [data-part="rule"]{
margin:0.15rem 0 0;font-size:0.75rem;color:var(--vibeui-calendar-030-muted);
}
[data-vibeui-block="calendar-030"] [data-part="grid"]{
display:grid;grid-template-columns:repeat(7,1fr);gap:0.15rem;
}
[data-vibeui-block="calendar-030"] [data-part="dow"]{
text-align:center;font-size:0.65rem;font-weight:700;text-transform:uppercase;
color:var(--vibeui-calendar-030-muted);padding-bottom:0.2rem;
}
[data-vibeui-block="calendar-030"] [data-part="day"]{
appearance:none;cursor:pointer;font:inherit;
height:2.1rem;border:0;border-radius:0.45rem;
background:var(--vibeui-calendar-030-soft);color:inherit;
font-size:0.8rem;font-variant-numeric:tabular-nums;
transition:background-color .16s ease,color .16s ease;
}
[data-vibeui-block="calendar-030"] [data-part="day"]:hover:not(:disabled){
background:var(--vibeui-calendar-030-accentsoft);
}
[data-vibeui-block="calendar-030"] [data-part="day"]:focus-visible{
outline:2px solid var(--vibeui-calendar-030-accent);outline-offset:2px;
}
[data-vibeui-block="calendar-030"] [data-part="day"]:disabled{
cursor:not-allowed;background:transparent;color:var(--vibeui-calendar-030-faint);
}
[data-vibeui-block="calendar-030"] [data-part="day"][data-inside="true"]{
background:var(--vibeui-calendar-030-accentsoft);
}
[data-vibeui-block="calendar-030"] [data-part="day"][data-edge="true"]{
background:var(--vibeui-calendar-030-accent);color:var(--vibeui-calendar-030-bg);font-weight:700;
}
[data-vibeui-block="calendar-030"] [data-part="foot"]{
display:flex;align-items:center;justify-content:space-between;gap:0.5rem;
padding-top:0.6rem;border-top:1px solid var(--vibeui-calendar-030-border);
}
[data-vibeui-block="calendar-030"] [data-part="count"]{
margin:0;font-size:0.8125rem;color:var(--vibeui-calendar-030-muted);
}
[data-vibeui-block="calendar-030"] [data-part="count"] b{
color:var(--vibeui-calendar-030-fg);font-variant-numeric:tabular-nums;
}
[data-vibeui-block="calendar-030"] [data-part="reset"]{
appearance:none;cursor:pointer;font:inherit;flex:none;
padding:0.35rem 0.7rem;border-radius:0.5rem;
border:1px solid var(--vibeui-calendar-030-border);
background:transparent;color:var(--vibeui-calendar-030-muted);
font-size:0.78rem;font-weight:600;
}
[data-vibeui-block="calendar-030"] [data-part="reset"]:focus-visible{
outline:2px solid var(--vibeui-calendar-030-accent);outline-offset:2px;
}
[data-vibeui-block="calendar-030"] [data-part="bar"]{
height:0.3rem;border-radius:999px;overflow:hidden;background:var(--vibeui-calendar-030-soft);
}
[data-vibeui-block="calendar-030"] [data-part="bar"] i{
display:block;height:100%;border-radius:inherit;
width:calc(var(--vibeui-calendar-030-fill,0) * 1%);
background:var(--vibeui-calendar-030-accent);
transition:width .2s ease;
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="calendar-030"] *{animation:none!important;transition:none!important}}
`
const DAY = 86400000
function stamp(date: Date) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, "0")
const day = String(date.getDate()).padStart(2, "0")
return `${year}-${month}-${day}`
}
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]
}
/**
* Диапазон с потолком в N дней: лишние дни гаснут сразу после первой даты.
* Один файл, ноль зависимостей, собственная палитра.
*/
export function Calendar030({
month = "2026-04",
maxDays = 14,
defaultFrom = "2026-04-06",
defaultTo = "2026-04-12",
locale = "ru-RU",
onChange,
accent,
className,
style,
...props
}: Calendar030Props) {
const [from, setFrom] = useState(defaultFrom)
const [to, setTo] = useState(defaultTo)
const [year, index] = month.split("-").map(Number)
const first = new Date(year, index - 1, 1)
const offset = (first.getDay() + 6) % 7
const total = new Date(year, index, 0).getDate()
const cells = Array.from(
{ length: Math.ceil((offset + total) / 7) * 7 },
(_, step) => new Date(year, index - 1, 1 - offset + step),
)
const weekdays = Array.from({ length: 7 }, (_, day) =>
new Intl.DateTimeFormat(locale, { weekday: "short" })
.format(new Date(2024, 0, 1 + day))
.slice(0, 2),
)
const start = from ? new Date(`${from}T00:00:00`).getTime() : 0
const finish = to ? new Date(`${to}T00:00:00`).getTime() : 0
const picking = Boolean(from) && !to
const blocked = (date: Date) => {
if (!picking) return false
const distance = (date.getTime() - start) / DAY
return distance < 0 || distance > maxDays - 1
}
const choose = (date: Date) => {
const value = stamp(date)
if (picking && date.getTime() >= start) {
setTo(value)
onChange?.(from, value)
return
}
setFrom(value)
setTo("")
onChange?.(value, "")
}
const length =
from && to ? Math.round((finish - start) / DAY) + 1 : from ? 1 : 0
const heading = new Intl.DateTimeFormat(locale, {
month: "long",
year: "numeric",
}).format(first)
const palette = {
"--vibeui-calendar-030-fill": Math.min(
100,
Math.round((length / maxDays) * 100),
),
...(accent ? { "--vibeui-calendar-030-accent": accent } : null),
...style,
} as CSSProperties
return (
<>
<style href="vibeui-calendar-030" precedence="medium">
{STYLES}
</style>
<section
{...props}
data-vibeui-block="calendar-030"
className={className}
style={palette}
>
<header>
<h3 data-part="title">{heading}</h3>
<p data-part="rule">
Не больше {maxDays} {pluralize(maxDays, ["дня", "дней", "дней"])}{" "}
подряд
</p>
</header>
<div data-part="grid">
{weekdays.map((name) => (
<span key={name} data-part="dow">
{name}
</span>
))}
{cells.map((date) => {
const time = date.getTime()
const edge = stamp(date) === from || stamp(date) === to
const inside = Boolean(to) && time > start && time < finish
return (
<button
key={time}
type="button"
data-part="day"
data-edge={edge}
data-inside={inside}
disabled={blocked(date) || date.getMonth() !== index - 1}
aria-pressed={edge || inside}
onClick={() => choose(date)}
>
{date.getDate()}
</button>
)
})}
</div>
<div data-part="bar" aria-hidden="true">
<i />
</div>
<div data-part="foot">
<p data-part="count" aria-live="polite">
{length === 0 ? (
"Выберите первый день"
) : picking ? (
<>
Начало выбрано, осталось до <b>{maxDays - 1}</b>{" "}
{pluralize(maxDays - 1, ["дня", "дней", "дней"])}
</>
) : (
<>
Выбрано <b>{length}</b> из <b>{maxDays}</b>{" "}
{pluralize(maxDays, ["дня", "дней", "дней"])}
</>
)}
</p>
<button
type="button"
data-part="reset"
onClick={() => {
setFrom("")
setTo("")
onChange?.("", "")
}}
>
Сбросить
</button>
</div>
</section>
</>
)
}