Event Calendar
Upcoming Feed
A feed of upcoming events grouped by day: the day header is sticky and «today» and «tomorrow» are named in words rather than dates.
- calendar
- events
- agenda
- list
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/eventcalendar-004?lang=en
Upcoming
7 дней вперёд
Сегодня17 марта · 3
- 30 минПланёрка командыЗал 2
- 1 чОбед с подрядчикомКафе внизу
- 45 минРазбор метрикMeet
Завтра18 марта · 2
- 1 ч 30 минДизайн-ревью каталогаЗал 1
- срокСдать сметудедлайн
пятница20 марта · 2
- 1 чБассейн
- 2 чВыезд к клиентуПр. Мира, 12
понедельник23 марта · 1
- 30 минРетро спринтаЗал 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-004" (Upcoming Feed) 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-004.json
Registry item: https://vibeui.ru/r/eventcalendar-004.json
Installs to: components/vibeui/eventcalendar-004.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 feed of upcoming events grouped by day: the day header is sticky and «today» and «tomorrow» are named in words rather than dates.
Upcoming events as a feed: grouped by day, sticky day headers, durations in words. Zero dependencies, one file, no client JS.
## 3. How to use it
import { Eventcalendar004 } from "@/components/vibeui/eventcalendar-004"
<Eventcalendar004
today="2026-03-17"
days={7}
events={[{ at: "2026-03-18T11:00", minutes: 90, title: "Review" }]}
/>
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-004-* palette — do not swap it for your theme tokens
- the sticky day header: while scrolling it is otherwise unclear which day a row belongs to
- «Today» and «Tomorrow» spelled out — nobody reads a date for those two days
- tabular figures in their own time column: proportional digits break the vertical rhythm
- the left border style as the type marker, not only its colour
- the card's own light surface: dark text disappears on a dark showcase background
## 6. You may change
- the events array and the reference point through today
- the window depth through days
- the caption through heading and the label language through locale
- the accent through accent and the maximum feed height
## 7. Rules
- Days without events are not shown: the feed answers «what is next», not «what does the week look like».
- Time is taken from the string as-is, with no time zones — convert the data beforehand.
- «Today» and «tomorrow» are hard-coded in Russian: another language needs them lifted into props.
- 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-004.jsonhttps://vibeui.ru/r/eventcalendar-004.jsonКомпонент самодостаточен: один файл, без зависимостей, собственная палитра --vibeui-eventcalendar-004-*. Серверный: хуков нет. События фильтруются по окну days от today, сортируются по строке ISO и группируются в один проход. Заголовок дня липкий внутри прокручиваемой ленты с ограниченной высотой, поэтому карточка в сайдбаре не растёт вместе с числом встреч. Время стоит отдельной колонкой с моноширинными цифрами, тип события различается стилем левой границы, дедлайн дополнительно помечен словом.
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 Eventcalendar004Event = {
/** Начало в виде «ГГГГ-ММ-ДДTЧЧ:ММ». */
at: string
/** Длительность в минутах. */
minutes?: number
title: string
place?: string
tone?: "work" | "personal" | "deadline"
}
export type Eventcalendar004Props = Omit<
ComponentPropsWithoutRef<"section">,
"children" | "title"
> & {
today?: string
events?: Eventcalendar004Event[]
days?: number
heading?: string
locale?: string
accent?: string
}
// Идея компонента: ближайшие события удобнее читать лентой, а не сеткой —
// в сетке половина клеток пустая. День становится липким заголовком, под ним
// идут события по возрастанию времени, и сколько бы их ни было, строка не
// ломается: время стоит отдельной колонкой с моноширинными цифрами.
// «Сегодня» и «Завтра» подписаны словами — дату в эти два дня никто не читает.
const STYLES = `
:where([data-vibeui-block="eventcalendar-004"]){
--vibeui-eventcalendar-004-bg:oklch(1 0 0);
--vibeui-eventcalendar-004-fg:oklch(0.23 0.014 265);
--vibeui-eventcalendar-004-muted:oklch(0.6 0.014 265);
--vibeui-eventcalendar-004-border:oklch(0.91 0.006 265);
--vibeui-eventcalendar-004-line:oklch(0.96 0.004 265);
--vibeui-eventcalendar-004-accent:oklch(0.55 0.16 262);
--vibeui-eventcalendar-004-personal:oklch(0.6 0.13 165);
--vibeui-eventcalendar-004-deadline:oklch(0.58 0.19 25);
--vibeui-eventcalendar-004-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="eventcalendar-004"]{
width:100%;max-width:30rem;box-sizing:border-box;
padding:1rem 1rem 0.5rem;
background:var(--vibeui-eventcalendar-004-bg);
border:1px solid var(--vibeui-eventcalendar-004-border);border-radius:1rem;
color:var(--vibeui-eventcalendar-004-fg);
font-family:var(--vibeui-eventcalendar-004-font);
}
[data-vibeui-block="eventcalendar-004"] *{box-sizing:border-box}
[data-vibeui-block="eventcalendar-004"] ol,
[data-vibeui-block="eventcalendar-004"] ul{margin:0;padding:0;list-style:none}
[data-vibeui-block="eventcalendar-004"] [data-part="head"]{
display:flex;flex-wrap:wrap;align-items:baseline;justify-content:space-between;
gap:0.5rem;margin:0 0 0.5rem;
}
[data-vibeui-block="eventcalendar-004"] [data-part="heading"]{
margin:0;font-size:0.9375rem;font-weight:700;letter-spacing:-0.01em;
}
[data-vibeui-block="eventcalendar-004"] [data-part="hint"]{
margin:0;font-size:0.75rem;color:var(--vibeui-eventcalendar-004-muted);
}
/* Лента прокручивается сама: карточка в сайдбаре не должна расти вместе
с числом встреч. */
[data-vibeui-block="eventcalendar-004"] [data-part="feed"]{
max-height:22rem;overflow-y:auto;
}
[data-vibeui-block="eventcalendar-004"] [data-part="feed"]:focus-visible{
outline:2px solid var(--vibeui-eventcalendar-004-accent);outline-offset:2px;
border-radius:0.5rem;
}
/* Заголовок дня липкий: при прокрутке видно, к какому дню относится строка. */
[data-vibeui-block="eventcalendar-004"] [data-part="dayhead"]{
position:sticky;top:0;z-index:1;
display:flex;align-items:baseline;justify-content:space-between;gap:0.5rem;
margin:0;padding:0.375rem 0;
background:var(--vibeui-eventcalendar-004-bg);
border-bottom:1px solid var(--vibeui-eventcalendar-004-border);
font-size:0.75rem;font-weight:700;
}
/* Заглавная только первая буква: capitalize поднимает и «марта» в «19 марта». */
[data-vibeui-block="eventcalendar-004"] [data-part="dayhead"]::first-letter{text-transform:uppercase}
[data-vibeui-block="eventcalendar-004"] [data-part="dayhead"] span{
font-weight:500;text-transform:none;font-variant-numeric:tabular-nums;
color:var(--vibeui-eventcalendar-004-muted);
}
[data-vibeui-block="eventcalendar-004"] [data-part="dayhead"][data-today="true"]{
color:var(--vibeui-eventcalendar-004-accent);
}
[data-vibeui-block="eventcalendar-004"] [data-part="row"]{
display:grid;grid-template-columns:3.25rem minmax(0,1fr);
gap:0.625rem;padding:0.5rem 0;
border-bottom:1px solid var(--vibeui-eventcalendar-004-line);
}
[data-vibeui-block="eventcalendar-004"] [data-part="when"]{
display:flex;flex-direction:column;gap:0.0625rem;
padding-left:0.5rem;
border-left:3px solid var(--vibeui-eventcalendar-004-accent);
font-variant-numeric:tabular-nums;
}
[data-vibeui-block="eventcalendar-004"] [data-part="when"] time{
font-size:0.8125rem;font-weight:650;line-height:1.1;
}
[data-vibeui-block="eventcalendar-004"] [data-part="when"] small{
font-size:0.5625rem;color:var(--vibeui-eventcalendar-004-muted);
}
[data-vibeui-block="eventcalendar-004"] [data-tone="personal"] [data-part="when"]{
border-left-color:var(--vibeui-eventcalendar-004-personal);
}
[data-vibeui-block="eventcalendar-004"] [data-tone="deadline"] [data-part="when"]{
border-left-color:var(--vibeui-eventcalendar-004-deadline);border-left-style:double;
border-left-width:4px;
}
[data-vibeui-block="eventcalendar-004"] [data-part="what"]{
display:flex;flex-direction:column;gap:0.125rem;min-width:0;
}
[data-vibeui-block="eventcalendar-004"] [data-part="what"] b{
font-size:0.8125rem;font-weight:600;line-height:1.3;
}
[data-vibeui-block="eventcalendar-004"] [data-part="what"] span{
font-size:0.6875rem;color:var(--vibeui-eventcalendar-004-muted);
}
[data-vibeui-block="eventcalendar-004"] [data-part="badge"]{
align-self:flex-start;margin-top:0.125rem;
padding:0.0625rem 0.3125rem;border-radius:0.25rem;
border:1px solid var(--vibeui-eventcalendar-004-deadline);
color:var(--vibeui-eventcalendar-004-deadline);
font-size:0.5625rem;font-weight:600;text-transform:uppercase;letter-spacing:0.04em;
}
[data-vibeui-block="eventcalendar-004"] [data-part="empty"]{
padding:0.75rem 0;font-size:0.75rem;color:var(--vibeui-eventcalendar-004-muted);
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="eventcalendar-004"] *{animation:none!important;transition:none!important}}
`
const DAY = 86400000
const DEFAULT_EVENTS: Eventcalendar004Event[] = [
{
at: "2026-03-17T09:30",
minutes: 30,
title: "Планёрка команды",
place: "Зал 2",
},
{
at: "2026-03-17T12:00",
minutes: 60,
title: "Обед с подрядчиком",
place: "Кафе внизу",
tone: "personal",
},
{
at: "2026-03-17T16:00",
minutes: 45,
title: "Разбор метрик",
place: "Meet",
},
{
at: "2026-03-18T11:00",
minutes: 90,
title: "Дизайн-ревью каталога",
place: "Зал 1",
},
{
at: "2026-03-18T18:00",
title: "Сдать смету",
tone: "deadline",
},
{
at: "2026-03-20T08:00",
minutes: 60,
title: "Бассейн",
tone: "personal",
},
{
at: "2026-03-20T14:00",
minutes: 120,
title: "Выезд к клиенту",
place: "Пр. Мира, 12",
},
{
at: "2026-03-23T10:00",
minutes: 30,
title: "Ретро спринта",
place: "Зал 2",
},
]
function duration(value: number) {
const hours = Math.floor(value / 60)
const rest = value % 60
if (hours === 0) {
return `${rest} мин`
}
return rest === 0 ? `${hours} ч` : `${hours} ч ${rest} мин`
}
/**
* Лента ближайших событий, сгруппированная по дням: заголовок дня липкий,
* «сегодня» и «завтра» названы словами. Один файл, ноль зависимостей.
*/
export function Eventcalendar004({
today = "2026-03-17",
events = DEFAULT_EVENTS,
days = 7,
heading = "Ближайшее",
locale = "ru-RU",
accent,
className,
style,
...props
}: Eventcalendar004Props) {
const from = new Date(`${today}T00:00:00Z`).getTime()
const to = from + days * DAY
const upcoming = events
.filter((event) => {
const stamp = new Date(`${event.at}:00Z`).getTime()
return stamp >= from && stamp < to
})
.sort((left, right) => left.at.localeCompare(right.at))
const groups: { key: string; offset: number; items: typeof upcoming }[] = []
for (const event of upcoming) {
const key = event.at.slice(0, 10)
const last = groups.at(-1)
if (last?.key === key) {
last.items.push(event)
continue
}
groups.push({
key,
offset: Math.round((new Date(`${key}T00:00:00Z`).getTime() - from) / DAY),
items: [event],
})
}
const dayName = (key: string, offset: number) => {
if (offset === 0) {
return "Сегодня"
}
if (offset === 1) {
return "Завтра"
}
return new Intl.DateTimeFormat(locale, {
weekday: "long",
timeZone: "UTC",
}).format(new Date(`${key}T00:00:00Z`))
}
const dayDate = (key: string) =>
new Intl.DateTimeFormat(locale, {
day: "numeric",
month: "long",
timeZone: "UTC",
}).format(new Date(`${key}T00:00:00Z`))
const palette = {
...(accent ? { "--vibeui-eventcalendar-004-accent": accent } : null),
...style,
} as CSSProperties
return (
<>
<style href="vibeui-eventcalendar-004" precedence="medium">
{STYLES}
</style>
<section
{...props}
data-vibeui-block="eventcalendar-004"
aria-label={heading}
className={className}
style={palette}
>
<header data-part="head">
<h3 data-part="heading">{heading}</h3>
<p data-part="hint">{days} дней вперёд</p>
</header>
<div
data-part="feed"
tabIndex={0}
role="group"
aria-label={`${heading}: список событий`}
>
{groups.length === 0 ? (
<p data-part="empty">На ближайшие дни ничего не запланировано.</p>
) : null}
{groups.map((group) => (
<section key={group.key} aria-label={dayDate(group.key)}>
<h4 data-part="dayhead" data-today={String(group.offset === 0)}>
{dayName(group.key, group.offset)}
<span>
{dayDate(group.key)} · {group.items.length}
</span>
</h4>
<ol>
{group.items.map((event) => (
<li
key={event.at + event.title}
data-part="row"
data-tone={event.tone ?? "work"}
>
<span data-part="when">
<time dateTime={event.at}>{event.at.slice(11, 16)}</time>
{event.minutes ? (
<small>{duration(event.minutes)}</small>
) : (
<small>срок</small>
)}
</span>
<span data-part="what">
<b>{event.title}</b>
{event.place ? <span>{event.place}</span> : null}
{event.tone === "deadline" ? (
<span data-part="badge">дедлайн</span>
) : null}
</span>
</li>
))}
</ol>
</section>
))}
</div>
</section>
</>
)
}