Event Calendar

Day Lanes

A day timeline where overlapping events split into lanes: the width comes from their own overlap group, not from the whole day.

  • calendar
  • events
  • day
  • overlap

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/eventcalendar-003?lang=en

вторник, 17 марта

8 событий, пересечений: 2

  1. ПланёркаЗал 2
  2. Фокус-блок
  3. Звонок клиентуMeet
  4. ИнтервьюЗал 1
  5. Обед
  6. Правки макетов
  7. Разбор инцидента
  8. РетроЗал 2
  • встреча
  • работа без встреч
  • под вопросом
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 "eventcalendar-003" (Day Lanes) 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/eventcalendar-003.json

Registry item: https://vibeui.ru/r/eventcalendar-003.json
Installs to: components/vibeui/eventcalendar-003.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 day timeline where overlapping events split into lanes: the width comes from their own overlap group, not from the whole day.

A day on an hour scale: overlaps are laid into lanes by a greedy pass at render time. Zero dependencies, one file, no client JS.

## 3. How to use it
import { Eventcalendar003 } from "@/components/vibeui/eventcalendar-003"

<Eventcalendar003
  date="2026-03-17"
  dayFrom={8}
  events={[{ from: "10:00", to: "12:00", title: "Focus", tone: "focus" }]}
/>

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-eventcalendar-003-* palette — do not swap it for your theme tokens
- width computed per overlap group, not per day: otherwise a quiet evening shrinks because of the morning
- the split into connected groups: without it lanes are never reused and the day turns into noodles
- the gap between neighbouring lanes — two rectangles pressed together read as one
- the time as text inside an event: a rectangle's height does not state a duration
- the card's own light surface: dark text disappears on a dark showcase background

## 6. You may change
- the events array and the date through date
- the scale bounds through dayFrom and dayTo
- the accent through accent, the hour height and the rest of the palette
- the legend captions and the heading language through locale

## 7. Rules
- The layout is greedy: it guarantees no overlap, not the prettiest distribution.
- An event outside dayFrom–dayTo is not clipped but runs off the canvas — filter the data beforehand.
- More than three simultaneous events make a column too narrow for its label.
- Do not lift the CSS variables into globals.css: the component has to stay a single file.

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

Компонент самодостаточен: один файл, без зависимостей, собственная палитра --vibeui-eventcalendar-003-*. Серверный: хуков нет, вся раскладка считается при рендере чистой функцией layout(). События сортируются по началу, разбиваются на связные группы пересечений, внутри группы каждое занимает первую свободную дорожку; ширина берётся от числа дорожек именно этой группы, поэтому одно наложение утром не сужает спокойный вечер. Часовые линии рисует repeating-linear-gradient, тип события различается цветом и стилем рамки.

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 Eventcalendar003Event = {
  from: string
  to: string
  title: string
  place?: string
  tone?: "work" | "focus" | "hold"
}

export type Eventcalendar003Props = Omit<
  ComponentPropsWithoutRef<"section">,
  "children" | "title"
> & {
  date?: string
  events?: Eventcalendar003Event[]
  dayFrom?: number
  dayTo?: number
  locale?: string
  accent?: string
}

// Идея компонента: день — единственный вид, где пересечения нельзя спрятать.
// События сначала разбиваются на связные группы по времени, внутри группы
// раскладываются по дорожкам жадным алгоритмом, и ширина считается от числа
// дорожек ИМЕННО этой группы: одно наложение в 11 утра не должно сужать
// спокойный вечер. Всё считается при рендере, состояния нет — компонент
// остаётся серверным.
const STYLES = `
:where([data-vibeui-block="eventcalendar-003"]){
--vibeui-eventcalendar-003-bg:oklch(1 0 0);
--vibeui-eventcalendar-003-fg:oklch(0.23 0.014 265);
--vibeui-eventcalendar-003-muted:oklch(0.6 0.014 265);
--vibeui-eventcalendar-003-border:oklch(0.91 0.006 265);
--vibeui-eventcalendar-003-line:oklch(0.95 0.004 265);
--vibeui-eventcalendar-003-accent:oklch(0.55 0.16 275);
--vibeui-eventcalendar-003-focus:oklch(0.6 0.13 165);
--vibeui-eventcalendar-003-hold:oklch(0.65 0.13 70);
--vibeui-eventcalendar-003-hour:3.25rem;
--vibeui-eventcalendar-003-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="eventcalendar-003"]{
width:100%;max-width:34rem;box-sizing:border-box;padding:1rem;
background:var(--vibeui-eventcalendar-003-bg);
border:1px solid var(--vibeui-eventcalendar-003-border);border-radius:1rem;
color:var(--vibeui-eventcalendar-003-fg);
font-family:var(--vibeui-eventcalendar-003-font);
}
[data-vibeui-block="eventcalendar-003"] *{box-sizing:border-box}
[data-vibeui-block="eventcalendar-003"] ol,
[data-vibeui-block="eventcalendar-003"] ul{margin:0;padding:0;list-style:none}
[data-vibeui-block="eventcalendar-003"] [data-part="head"]{
display:flex;flex-wrap:wrap;align-items:baseline;justify-content:space-between;
gap:0.5rem;margin:0 0 0.875rem;
}
[data-vibeui-block="eventcalendar-003"] [data-part="heading"]{
margin:0;font-size:1rem;font-weight:700;letter-spacing:-0.01em;
}
/* Заглавная только первая буква: capitalize поднимает и «г.» в «март 2026 г.». */
[data-vibeui-block="eventcalendar-003"] [data-part="heading"]::first-letter{text-transform:uppercase}
[data-vibeui-block="eventcalendar-003"] [data-part="hint"]{
margin:0;font-size:0.75rem;color:var(--vibeui-eventcalendar-003-muted);
}
[data-vibeui-block="eventcalendar-003"] [data-part="board"]{
display:grid;grid-template-columns:2.875rem minmax(0,1fr);
}
[data-vibeui-block="eventcalendar-003"] [data-part="axis"]{
display:flex;flex-direction:column;
}
[data-vibeui-block="eventcalendar-003"] [data-part="hour"]{
height:var(--vibeui-eventcalendar-003-hour);
padding-right:0.4375rem;text-align:right;
font-size:0.625rem;font-variant-numeric:tabular-nums;line-height:1;
color:var(--vibeui-eventcalendar-003-muted);
transform:translateY(-0.3em);
}
/* Полотно дня: одна система координат в процентах от рабочего интервала. */
[data-vibeui-block="eventcalendar-003"] [data-part="canvas"]{
position:relative;
height:calc(var(--vibeui-eventcalendar-003-span,10) * var(--vibeui-eventcalendar-003-hour));
border-left:1px solid var(--vibeui-eventcalendar-003-border);
background:repeating-linear-gradient(
to bottom,
var(--vibeui-eventcalendar-003-line) 0 1px,
transparent 1px var(--vibeui-eventcalendar-003-hour));
}
[data-vibeui-block="eventcalendar-003"] [data-part="event"]{
position:absolute;
display:flex;flex-direction:column;gap:0.0625rem;overflow:hidden;
padding:0.25rem 0.4375rem;
border:1px solid var(--vibeui-eventcalendar-003-accent);
border-left-width:0.1875rem;border-radius:0.4375rem;
background:color-mix(in oklab,var(--vibeui-eventcalendar-003-accent) 12%,var(--vibeui-eventcalendar-003-bg));
font-size:0.6875rem;line-height:1.25;
}
[data-vibeui-block="eventcalendar-003"] [data-part="event"] b{font-weight:650}
[data-vibeui-block="eventcalendar-003"] [data-part="event"] time,
[data-vibeui-block="eventcalendar-003"] [data-part="event"] small{
font-size:0.5625rem;color:var(--vibeui-eventcalendar-003-muted);
font-variant-numeric:tabular-nums;
}
[data-vibeui-block="eventcalendar-003"] [data-tone="focus"]{
border-color:var(--vibeui-eventcalendar-003-focus);
background:color-mix(in oklab,var(--vibeui-eventcalendar-003-focus) 12%,var(--vibeui-eventcalendar-003-bg));
}
[data-vibeui-block="eventcalendar-003"] [data-tone="hold"]{
border-color:var(--vibeui-eventcalendar-003-hold);border-style:dashed;
background:color-mix(in oklab,var(--vibeui-eventcalendar-003-hold) 12%,var(--vibeui-eventcalendar-003-bg));
}
/* Соседняя дорожка сдвинута и слегка приподнята тенью: без разделения
   два прижатых прямоугольника читаются как один. */
[data-vibeui-block="eventcalendar-003"] [data-part="event"][data-lane="1"],
[data-vibeui-block="eventcalendar-003"] [data-part="event"][data-lane="2"]{
box-shadow:-2px 0 0 var(--vibeui-eventcalendar-003-bg);
}
[data-vibeui-block="eventcalendar-003"] [data-part="legend"]{
display:flex;flex-wrap:wrap;gap:0.875rem;margin:0.75rem 0 0;padding:0;
list-style:none;font-size:0.6875rem;color:var(--vibeui-eventcalendar-003-muted);
}
[data-vibeui-block="eventcalendar-003"] [data-part="legend"] li{
display:inline-flex;align-items:center;gap:0.375rem;
}
[data-vibeui-block="eventcalendar-003"] [data-part="legend"] i{
width:0.75rem;height:0.75rem;border-radius:0.25rem;
border:1px solid var(--vibeui-eventcalendar-003-accent);
background:color-mix(in oklab,var(--vibeui-eventcalendar-003-accent) 20%,transparent);
}
[data-vibeui-block="eventcalendar-003"] [data-part="legend"] i[data-tone="focus"]{
border-color:var(--vibeui-eventcalendar-003-focus);
background:color-mix(in oklab,var(--vibeui-eventcalendar-003-focus) 20%,transparent);
}
[data-vibeui-block="eventcalendar-003"] [data-part="legend"] i[data-tone="hold"]{
border-color:var(--vibeui-eventcalendar-003-hold);border-style:dashed;
background:color-mix(in oklab,var(--vibeui-eventcalendar-003-hold) 20%,transparent);
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="eventcalendar-003"] *{animation:none!important;transition:none!important}}
`

const DEFAULT_EVENTS: Eventcalendar003Event[] = [
  { from: "09:00", to: "09:30", title: "Планёрка", place: "Зал 2" },
  { from: "10:00", to: "12:00", title: "Фокус-блок", tone: "focus" },
  { from: "10:30", to: "11:15", title: "Звонок клиенту", place: "Meet" },
  { from: "11:00", to: "11:45", title: "Интервью", place: "Зал 1" },
  { from: "13:00", to: "14:00", title: "Обед" },
  { from: "14:30", to: "16:00", title: "Правки макетов", tone: "focus" },
  { from: "15:30", to: "16:30", title: "Разбор инцидента", tone: "hold" },
  { from: "17:00", to: "18:00", title: "Ретро", place: "Зал 2" },
]

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

/**
 * Раскладка событий дня по дорожкам. Группа — связная цепочка пересечений;
 * внутри группы каждое событие занимает первую дорожку, где оно помещается.
 */
function layout(events: Eventcalendar003Event[]) {
  const sorted = [...events].sort(
    (left, right) => minutes(left.from) - minutes(right.from),
  )

  const placed: {
    event: Eventcalendar003Event
    from: number
    to: number
    lane: number
    group: number
  }[] = []

  const groupWidth: number[] = []
  let lanes: number[] = []
  let group = -1

  for (const event of sorted) {
    const from = minutes(event.from)
    const to = minutes(event.to)

    if (lanes.every((end) => end <= from)) {
      lanes = []
      group += 1
      groupWidth[group] = 0
    }

    let lane = lanes.findIndex((end) => end <= from)

    if (lane === -1) {
      lane = lanes.length
    }

    lanes[lane] = to
    groupWidth[group] = Math.max(groupWidth[group], lanes.length)
    placed.push({ event, from, to, lane, group })
  }

  return { placed, groupWidth }
}

/**
 * День с временной шкалой: пересекающиеся события расходятся по дорожкам
 * внутри своей группы. Один файл, ноль зависимостей, клиентского JS нет.
 */
export function Eventcalendar003({
  date = "2026-03-17",
  events = DEFAULT_EVENTS,
  dayFrom = 8,
  dayTo = 19,
  locale = "ru-RU",
  accent,
  className,
  style,
  ...props
}: Eventcalendar003Props) {
  const span = (dayTo - dayFrom) * 60
  const hours = Array.from({ length: dayTo - dayFrom }, (_, i) => dayFrom + i)
  const { placed, groupWidth } = layout(events)
  const overlaps = groupWidth.filter((width) => width > 1).length

  const label = new Intl.DateTimeFormat(locale, {
    weekday: "long",
    day: "numeric",
    month: "long",
    timeZone: "UTC",
  }).format(new Date(`${date}T00:00:00Z`))

  const palette = {
    "--vibeui-eventcalendar-003-span": hours.length,
    ...(accent ? { "--vibeui-eventcalendar-003-accent": accent } : null),
    ...style,
  } as CSSProperties

  return (
    <>
      <style href="vibeui-eventcalendar-003" precedence="medium">
        {STYLES}
      </style>
      <section
        {...props}
        data-vibeui-block="eventcalendar-003"
        aria-label={label}
        className={className}
        style={palette}
      >
        <header data-part="head">
          <h3 data-part="heading">{label}</h3>
          <p data-part="hint">
            {events.length} событий, пересечений: {overlaps}
          </p>
        </header>

        <div data-part="board">
          <div data-part="axis" aria-hidden="true">
            {hours.map((hour) => (
              <span key={hour} data-part="hour">
                {String(hour).padStart(2, "0")}:00
              </span>
            ))}
          </div>

          <ol data-part="canvas">
            {placed.map((item) => {
              const width = groupWidth[item.group]
              const slice = 100 / width

              return (
                <li
                  key={item.event.title}
                  data-part="event"
                  data-tone={item.event.tone ?? "work"}
                  data-lane={item.lane}
                  style={{
                    top: `${((item.from - dayFrom * 60) / span) * 100}%`,
                    height: `${((item.to - item.from) / span) * 100}%`,
                    left: `calc(${item.lane * slice}% + 0.25rem)`,
                    width: `calc(${slice}% - 0.5rem)`,
                  }}
                >
                  <b>{item.event.title}</b>
                  <time>
                    {item.event.from}–{item.event.to}
                  </time>
                  {item.event.place ? <small>{item.event.place}</small> : null}
                </li>
              )
            })}
          </ol>
        </div>

        <ul data-part="legend">
          <li>
            <i /> встреча
          </li>
          <li>
            <i data-tone="focus" /> работа без встреч
          </li>
          <li>
            <i data-tone="hold" /> под вопросом
          </li>
        </ul>
      </section>
    </>
  )
}