Calendar

Holiday Shapes

A month where a weekend, a public holiday and a moved working day differ in shape: a fill, a diamond around the number and a dashed border.

  • calendar
  • holidays
  • legend
  • a11y

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

январь 2026 г.

пнвтсрчтптсбвс
2930311234
567891011
12131415161718
19202122232425
2627282930311
2345678

выходнойпраздникрабочий перенос

  • 1Новый год
  • 2Новогодние каникулы
  • 5Новогодние каникулы
  • 7Рождество
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-017" (Holiday Shapes) 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-017.json

Registry item: https://vibeui.ru/r/calendar-017.json
Installs to: components/vibeui/calendar-017.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 month where a weekend, a public holiday and a moved working day differ in shape: a fill, a diamond around the number and a dashed border.

A month with holidays and weekends encoded by shape — fill, diamond and dashes — plus a legend and a list of names. Server-side, one file, zero dependencies.

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

<Calendar017
  year={2026}
  month={1}
  holidays={{ 1: "New Year", 7: "Christmas" }}
  workdays={[17]}
/>

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-017-* palette — with foreign tokens the block stops matching the preview
- encoding by shape rather than colour alone: colour is indistinguishable under colour blindness and in black-and-white print
- rotating the pseudo-element, not the cell: a rotated cell lays the digit on its side
- the legend using the same shapes as the grid: without it the diamond stays a riddle
- the list of holiday names: shape says "special day" but not which one
- the component's own light surface with a border: dark digits vanish on the dark catalogue card

## 6. You may change
- the month and the year through month and year
- the holidays through holidays — a map of day number to name
- the moved working days through workdays
- the label language through locale and the accent through accent

## 7. Rules
- A holiday beats a weekend: if the first falls on a Sunday, the cell gets the diamond, not the fill.
- Holiday names are listed in ascending day order because the map keys are numeric — object order does not affect it.
- The diamond is drawn from the cell inset: change the cell height and the inset must be recomputed or the shape skews.
- Neighbouring months' days are deliberately left unmarked: other months' holidays confuse the grid rather than help it.

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

Серверный компонент: праздники приходят словарём holidays, переносы — массивом workdays, состояния нет. Главный приём — кодирование типом формы, а не только цветом: выходной получает заливку клетки, праздник — ромб, нарисованный псевдоэлементом с поворотом на сорок пять градусов, перенос — пунктирную рамку. Число внутри ромба остаётся прямым, потому что повёрнут псевдоэлемент, а не клетка: поворот клетки положил бы цифру набок. Такой месяц одинаково читается при дальтонизме и на чёрно-белой печати. Под сеткой стоят легенда теми же формами и список праздников с названиями — форма без подписи всё равно не объясняет, что за день.

Component source

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

import type { ComponentPropsWithoutRef, CSSProperties } from "react"

export type Calendar017Props = Omit<
  ComponentPropsWithoutRef<"div">,
  "children"
> & {
  year?: number
  month?: number
  /** Праздники месяца: ключ — число, значение — название. */
  holidays?: Record<number, string>
  workdays?: number[]
  locale?: string
  accent?: string
}

// Идея компонента: выходной и праздник различаются формой, а не только
// цветом. Выходной — заливка клетки, праздник — ромб вокруг числа,
// перенесённый рабочий день — пунктирная рамка. Дальтоник и чёрно-белая
// печать читают такой месяц так же, как и все остальные.
const STYLES = `
:where([data-vibeui-block="calendar-017"]){
--vibeui-calendar-017-bg:oklch(1 0 0);
--vibeui-calendar-017-fg:oklch(0.24 0.014 265);
--vibeui-calendar-017-muted:oklch(0.62 0.014 265);
--vibeui-calendar-017-border:oklch(0.91 0.006 265);
--vibeui-calendar-017-weekend:oklch(0.95 0.012 265);
--vibeui-calendar-017-accent:oklch(0.55 0.16 25);
--vibeui-calendar-017-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="calendar-017"]{
display:flex;flex-direction:column;gap:0.75rem;
width:100%;max-width:21rem;box-sizing:border-box;padding:0.9375rem;
background:var(--vibeui-calendar-017-bg);
border:1px solid var(--vibeui-calendar-017-border);border-radius:1rem;
color:var(--vibeui-calendar-017-fg);font-family:var(--vibeui-calendar-017-font);
}
[data-vibeui-block="calendar-017"] [data-part="title"]{
margin:0;font-size:0.9375rem;font-weight:700;letter-spacing:-0.01em;
}
/* Заглавная только первая буква: capitalize поднимает и «г.» в «январь 2026 г.». */
[data-vibeui-block="calendar-017"] [data-part="title"]::first-letter{text-transform:uppercase}
[data-vibeui-block="calendar-017"] table{width:100%;border-collapse:separate;border-spacing:0.125rem;table-layout:fixed}
[data-vibeui-block="calendar-017"] th{
padding:0 0 0.125rem;font-size:0.6875rem;font-weight:600;
color:var(--vibeui-calendar-017-muted);text-transform:capitalize;
}
[data-vibeui-block="calendar-017"] th[data-weekend="true"]{color:var(--vibeui-calendar-017-accent)}
[data-vibeui-block="calendar-017"] td{padding:0}
[data-vibeui-block="calendar-017"] [data-part="cell"]{
position:relative;display:flex;align-items:center;justify-content:center;
height:2.125rem;border-radius:0.4375rem;
font-size:0.8125rem;font-variant-numeric:tabular-nums;
}
[data-vibeui-block="calendar-017"] [data-part="cell"][data-kind="weekend"]{
background:var(--vibeui-calendar-017-weekend);
}
/* Ромб рисуется псевдоэлементом и повёрнут, а само число остаётся прямым:
   повернуть клетку целиком — значит положить цифру набок. */
[data-vibeui-block="calendar-017"] [data-part="cell"][data-kind="holiday"]::before{
content:"";position:absolute;inset:0.1875rem;border-radius:0.1875rem;
border:1.5px solid var(--vibeui-calendar-017-accent);
transform:rotate(45deg);
}
[data-vibeui-block="calendar-017"] [data-part="cell"][data-kind="holiday"]{
color:var(--vibeui-calendar-017-accent);font-weight:700;
}
[data-vibeui-block="calendar-017"] [data-part="cell"][data-kind="workday"]{
border:1.5px dashed var(--vibeui-calendar-017-border);
}
[data-vibeui-block="calendar-017"] [data-part="cell"][data-outside="true"]{color:var(--vibeui-calendar-017-muted);opacity:.4}
[data-vibeui-block="calendar-017"] [data-part="cell"] span{position:relative}
[data-vibeui-block="calendar-017"] [data-part="legend"]{
display:flex;flex-wrap:wrap;gap:0.75rem;margin:0;
font-size:0.6875rem;color:var(--vibeui-calendar-017-muted);
}
[data-vibeui-block="calendar-017"] [data-part="legend"] span{display:inline-flex;align-items:center;gap:0.375rem}
[data-vibeui-block="calendar-017"] [data-part="chip"]{
width:0.75rem;height:0.75rem;border-radius:0.1875rem;flex:none;
background:var(--vibeui-calendar-017-weekend);
}
[data-vibeui-block="calendar-017"] [data-part="chip"][data-kind="holiday"]{
background:transparent;border:1.5px solid var(--vibeui-calendar-017-accent);transform:rotate(45deg) scale(.8);
}
[data-vibeui-block="calendar-017"] [data-part="chip"][data-kind="workday"]{
background:transparent;border:1.5px dashed var(--vibeui-calendar-017-border);
}
[data-vibeui-block="calendar-017"] [data-part="list"]{
margin:0;padding:0;list-style:none;display:flex;flex-direction:column;gap:0.25rem;
padding-top:0.625rem;border-top:1px solid var(--vibeui-calendar-017-border);
font-size:0.75rem;
}
[data-vibeui-block="calendar-017"] [data-part="list"] li{display:flex;gap:0.5rem}
[data-vibeui-block="calendar-017"] [data-part="list"] b{
flex:none;width:1.75rem;color:var(--vibeui-calendar-017-accent);
font-variant-numeric:tabular-nums;font-weight:700;
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="calendar-017"] *{animation:none!important;transition:none!important}}
`

const DAY = 86400000

const DEFAULT_HOLIDAYS: Record<number, string> = {
  1: "Новый год",
  2: "Новогодние каникулы",
  5: "Новогодние каникулы",
  7: "Рождество",
}

/**
 * Месяц, где выходной, праздник и перенесённый рабочий день различаются формой.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Calendar017({
  year = 2026,
  month = 1,
  holidays = DEFAULT_HOLIDAYS,
  workdays = [17],
  locale = "ru-RU",
  accent,
  className,
  style,
  ...props
}: Calendar017Props) {
  const first = new Date(year, month - 1, 1)
  const start = new Date(first.getTime() - ((first.getDay() + 6) % 7) * DAY)
  const cells = Array.from(
    { length: 42 },
    (_, index) => new Date(start.getTime() + index * DAY),
  )

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

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

  const kindOf = (date: Date, outside: boolean) => {
    const day = date.getDate()
    const weekend = date.getDay() === 0 || date.getDay() === 6

    if (outside) {
      return "plain"
    }

    if (holidays[day]) {
      return "holiday"
    }

    if (workdays.includes(day)) {
      return "workday"
    }

    return weekend ? "weekend" : "plain"
  }

  return (
    <>
      <style href="vibeui-calendar-017" precedence="medium">
        {STYLES}
      </style>
      <div
        {...props}
        data-vibeui-block="calendar-017"
        className={className}
        style={palette}
      >
        <p data-part="title">{title}</p>
        <table>
          <thead>
            <tr>
              {cells.slice(0, 7).map((date) => (
                <th
                  key={date.toISOString()}
                  scope="col"
                  data-weekend={date.getDay() === 0 || date.getDay() === 6}
                >
                  {weekday.format(date)}
                </th>
              ))}
            </tr>
          </thead>
          <tbody>
            {Array.from({ length: 6 }, (_, row) => (
              <tr key={row}>
                {cells.slice(row * 7, row * 7 + 7).map((date) => {
                  const outside = date.getMonth() !== month - 1
                  const kind = kindOf(date, outside)
                  const label = holidays[date.getDate()]

                  return (
                    <td key={date.toISOString()}>
                      <span
                        data-part="cell"
                        data-kind={kind}
                        data-outside={outside}
                        title={outside ? undefined : label}
                        aria-label={
                          outside || !label
                            ? undefined
                            : `${date.getDate()} — ${label}`
                        }
                      >
                        <span>{date.getDate()}</span>
                      </span>
                    </td>
                  )
                })}
              </tr>
            ))}
          </tbody>
        </table>
        <p data-part="legend">
          <span>
            <i data-part="chip" aria-hidden="true" />
            выходной
          </span>
          <span>
            <i data-part="chip" data-kind="holiday" aria-hidden="true" />
            праздник
          </span>
          <span>
            <i data-part="chip" data-kind="workday" aria-hidden="true" />
            рабочий перенос
          </span>
        </p>
        <ul data-part="list">
          {Object.entries(holidays).map(([day, name]) => (
            <li key={day}>
              <b>{day}</b>
              <span>{name}</span>
            </li>
          ))}
        </ul>
      </div>
    </>
  )
}