Charts

Dependency Arrows

A plan with «starts after» links: an SVG layer draws the arrows from the same coordinates as the bars, so the chart shows why a task cannot start sooner.

  • gantt
  • dependencies
  • svg
  • timeline

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/gantt-002?lang=en

Linked plan

стрелка — «не раньше, чем закончится»

Спецификациястарт плана
Схема данныхпосле: Спецификация
Экраныпосле: Спецификация
Синхронизацияпосле: Схема данных
Тестированиепосле: Синхронизация
Релизпосле: Тестирование
3 дн
4 дн
5 дн
3 дн
3 дн
2 дн
Те же сроки таблицей
ЗадачаНачалоКонецПосле
Спецификация2026-03-022026-03-04
Схема данных2026-03-052026-03-08Спецификация
Экраны2026-03-052026-03-09Спецификация
Синхронизация2026-03-092026-03-11Схема данных
Тестирование2026-03-122026-03-14Синхронизация
Релиз2026-03-152026-03-16Тестирование
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 "gantt-002" (Dependency Arrows) 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/gantt-002.json

Registry item: https://vibeui.ru/r/gantt-002.json
Installs to: components/vibeui/gantt-002.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 plan with «starts after» links: an SVG layer draws the arrows from the same coordinates as the bars, so the chart shows why a task cannot start sooner.

A Gantt chart with dependency arrows: bar and link coordinates come from the same numbers. Zero dependencies, one file, no client JS.

## 3. How to use it
import { Gantt002 } from "@/components/vibeui/gantt-002"

<Gantt002
  startDate="2026-03-02"
  tasks={[
    { id: "spec", title: "Spec", start: 0, days: 3 },
    { id: "api", title: "Schema", start: 3, days: 4, after: "spec" },
  ]}
/>

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-gantt-002-* palette — do not swap it for your theme tokens
- the shared DAY_WIDTH and ROW_HEIGHT numbers for bars and SVG: once they drift the arrow starts lying
- the scroll container with overflow-x and tabindex=0 — a plan is wider than the screen almost always, and it has to be scrollable from the keyboard
- the exact-dates table in <details>: dates cannot be read off arrows and bars, and a screen reader needs text
- the sticky name column with its «after: X» line — a dependency must be readable without tracing lines
- the card's own light surface: dark text disappears on a dark showcase background

## 6. You may change
- the tasks array together with its after links
- the plan start through startDate
- the caption through heading
- the accent through accent and the link colour in the palette

## 7. Rules
- One link per task: several predecessors would need after to become an array.
- A task must sit after its predecessor in the array — row order defines the vertical run of the arrows.
- The arrow marker id is global to the page: two instances share it, but they draw identically.
- 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/gantt-002.json
https://vibeui.ru/r/gantt-002.json

Компонент самодостаточен: один файл, без зависимостей, собственная палитра --vibeui-gantt-002-*. Серверный: хуков нет. Ширина дня и высота строки заданы числами в коде (DAY_WIDTH, ROW_HEIGHT), полосы позиционируются абсолютно, а связи рисуются одним inline-SVG поверх полотна теми же числами — рассинхрона между стрелкой и прямоугольником быть не может. Колонка названий липкая и несёт подпись «после: X», прокрутка вбок вынесена в контейнер с tabindex=0, точные даты продублированы таблицей в <details>.

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 Gantt002Task = {
  id: string
  title: string
  /** Смещение начала в днях от начала плана. */
  start: number
  /** Длительность в днях. */
  days: number
  /** id задачи, после которой эта начинается. */
  after?: string
  tone?: "work" | "risk" | "done"
}

export type Gantt002Props = Omit<
  ComponentPropsWithoutRef<"section">,
  "children" | "title"
> & {
  heading?: string
  startDate?: string
  tasks?: Gantt002Task[]
  accent?: string
}

// Идея компонента: план без связей врёт — из него не видно, почему задача
// не может начаться раньше. Связи рисуются одним SVG-слоем поверх полос:
// координаты считаются из тех же чисел, что и полосы, поэтому стрелка не
// уезжает от прямоугольника. Ширина дня и высота строки заданы числами в
// коде: SVG и полосы обязаны жить в одной системе координат, иначе рассинхрон.
const STYLES = `
:where([data-vibeui-block="gantt-002"]){
--vibeui-gantt-002-bg:oklch(1 0 0);
--vibeui-gantt-002-fg:oklch(0.23 0.014 265);
--vibeui-gantt-002-muted:oklch(0.6 0.014 265);
--vibeui-gantt-002-border:oklch(0.91 0.006 265);
--vibeui-gantt-002-line:oklch(0.95 0.004 265);
--vibeui-gantt-002-accent:oklch(0.55 0.16 262);
--vibeui-gantt-002-risk:oklch(0.62 0.16 45);
--vibeui-gantt-002-done:oklch(0.6 0.12 165);
--vibeui-gantt-002-link:oklch(0.55 0.03 265);
--vibeui-gantt-002-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="gantt-002"]{
width:100%;box-sizing:border-box;padding:1rem;
background:var(--vibeui-gantt-002-bg);
border:1px solid var(--vibeui-gantt-002-border);border-radius:1rem;
color:var(--vibeui-gantt-002-fg);font-family:var(--vibeui-gantt-002-font);
}
[data-vibeui-block="gantt-002"] *{box-sizing:border-box}
[data-vibeui-block="gantt-002"] [data-part="head"]{
display:flex;flex-wrap:wrap;align-items:baseline;justify-content:space-between;
gap:0.5rem;margin:0 0 0.75rem;
}
[data-vibeui-block="gantt-002"] [data-part="heading"]{
margin:0;font-size:0.9375rem;font-weight:700;letter-spacing:-0.01em;
}
[data-vibeui-block="gantt-002"] [data-part="hint"]{
margin:0;font-size:0.75rem;color:var(--vibeui-gantt-002-muted);
}
/* Прокрутка живёт в своём контейнере и получает фокус: план шире экрана
   почти всегда, и с клавиатуры его тоже нужно уметь пролистать. */
[data-vibeui-block="gantt-002"] [data-part="scroll"]{
overflow-x:auto;border:1px solid var(--vibeui-gantt-002-line);border-radius:0.625rem;
}
[data-vibeui-block="gantt-002"] [data-part="scroll"]:focus-visible{
outline:2px solid var(--vibeui-gantt-002-accent);outline-offset:2px;
}
[data-vibeui-block="gantt-002"] [data-part="shell"]{display:flex}
[data-vibeui-block="gantt-002"] [data-part="names"]{
position:sticky;left:0;z-index:2;flex:none;width:9.5rem;
background:var(--vibeui-gantt-002-bg);
border-right:1px solid var(--vibeui-gantt-002-border);
}
[data-vibeui-block="gantt-002"] [data-part="name"]{
display:flex;flex-direction:column;justify-content:center;
padding:0 0.625rem;font-size:0.75rem;font-weight:600;
border-top:1px solid var(--vibeui-gantt-002-line);
}
[data-vibeui-block="gantt-002"] [data-part="name"] small{
font-size:0.625rem;font-weight:400;color:var(--vibeui-gantt-002-muted);
}
[data-vibeui-block="gantt-002"] [data-part="plot"]{position:relative;flex:none}
[data-vibeui-block="gantt-002"] [data-part="scale"]{
display:flex;font-size:0.5625rem;color:var(--vibeui-gantt-002-muted);
}
[data-vibeui-block="gantt-002"] [data-part="day"]{
flex:none;text-align:center;padding:0.25rem 0;
border-left:1px solid var(--vibeui-gantt-002-line);
font-variant-numeric:tabular-nums;
}
[data-vibeui-block="gantt-002"] [data-part="canvas"]{
position:relative;
background:repeating-linear-gradient(
to right,
var(--vibeui-gantt-002-line) 0 1px,
transparent 1px var(--vibeui-gantt-002-daywidth,2.125rem));
}
[data-vibeui-block="gantt-002"] [data-part="bar"]{
position:absolute;display:flex;align-items:center;
padding:0 0.4375rem;border-radius:0.375rem;
background:color-mix(in oklab,var(--vibeui-gantt-002-accent) 18%,var(--vibeui-gantt-002-bg));
border:1px solid var(--vibeui-gantt-002-accent);
font-size:0.625rem;line-height:1;white-space:nowrap;overflow:hidden;
font-variant-numeric:tabular-nums;
}
[data-vibeui-block="gantt-002"] [data-tone="risk"]{
border-color:var(--vibeui-gantt-002-risk);border-style:dashed;
background:color-mix(in oklab,var(--vibeui-gantt-002-risk) 18%,var(--vibeui-gantt-002-bg));
}
[data-vibeui-block="gantt-002"] [data-tone="done"]{
border-color:var(--vibeui-gantt-002-done);
background:color-mix(in oklab,var(--vibeui-gantt-002-done) 18%,var(--vibeui-gantt-002-bg));
}
[data-vibeui-block="gantt-002"] svg{position:absolute;inset:0;pointer-events:none}
[data-vibeui-block="gantt-002"] [data-part="table"]{margin:0.75rem 0 0}
[data-vibeui-block="gantt-002"] summary{
cursor:pointer;font-size:0.75rem;font-weight:600;
color:var(--vibeui-gantt-002-accent);
}
[data-vibeui-block="gantt-002"] summary:focus-visible{
outline:2px solid var(--vibeui-gantt-002-accent);outline-offset:2px;border-radius:0.25rem;
}
[data-vibeui-block="gantt-002"] table{
width:100%;margin-top:0.5rem;border-collapse:collapse;font-size:0.6875rem;
}
[data-vibeui-block="gantt-002"] th,
[data-vibeui-block="gantt-002"] td{
padding:0.25rem 0.375rem;text-align:left;
border-bottom:1px solid var(--vibeui-gantt-002-line);
}
[data-vibeui-block="gantt-002"] thead th{color:var(--vibeui-gantt-002-muted);font-weight:600}
[data-vibeui-block="gantt-002"] td{font-variant-numeric:tabular-nums}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="gantt-002"] *{animation:none!important;transition:none!important}}
`

/** Ширина дня и высота строки в пикселях: полосы и SVG считаются от них. */
const DAY_WIDTH = 34
const ROW_HEIGHT = 34
const BAR_HEIGHT = 22
const DAY = 86400000

const DEFAULT_TASKS: Gantt002Task[] = [
  { id: "spec", title: "Спецификация", start: 0, days: 3, tone: "done" },
  { id: "api", title: "Схема данных", start: 3, days: 4, after: "spec" },
  { id: "ui", title: "Экраны", start: 3, days: 5, after: "spec" },
  { id: "sync", title: "Синхронизация", start: 7, days: 3, after: "api" },
  {
    id: "qa",
    title: "Тестирование",
    start: 10,
    days: 3,
    after: "sync",
    tone: "risk",
  },
  { id: "ship", title: "Релиз", start: 13, days: 2, after: "qa" },
]

/**
 * План со связями «после чего»: стрелки рисуются SVG-слоем по тем же
 * координатам, что и полосы. Один файл, ноль зависимостей.
 */
export function Gantt002({
  heading = "План со связями",
  startDate = "2026-03-02",
  tasks = DEFAULT_TASKS,
  accent,
  className,
  style,
  ...props
}: Gantt002Props) {
  const total = Math.max(...tasks.map((task) => task.start + task.days))
  const width = total * DAY_WIDTH
  const height = tasks.length * ROW_HEIGHT
  const origin = new Date(`${startDate}T00:00:00Z`).getTime()

  const dayLabel = (offset: number) =>
    new Date(origin + offset * DAY).getUTCDate()

  const rowOf = (id: string) => tasks.findIndex((task) => task.id === id)

  const links = tasks.flatMap((task, row) => {
    const from = task.after ? rowOf(task.after) : -1

    if (from < 0) {
      return []
    }

    const parent = tasks[from]
    const x1 = (parent.start + parent.days) * DAY_WIDTH
    const y1 = from * ROW_HEIGHT + ROW_HEIGHT / 2
    const x2 = task.start * DAY_WIDTH
    const y2 = row * ROW_HEIGHT + ROW_HEIGHT / 2
    const turn = Math.max(x1 + 8, x2 - 10)

    return [
      {
        key: `${parent.id}-${task.id}`,
        points: `${x1},${y1} ${turn},${y1} ${turn},${y2} ${x2 - 5},${y2}`,
      },
    ]
  })

  const palette = {
    "--vibeui-gantt-002-daywidth": `${DAY_WIDTH}px`,
    ...(accent ? { "--vibeui-gantt-002-accent": accent } : null),
    ...style,
  } as CSSProperties

  return (
    <>
      <style href="vibeui-gantt-002" precedence="medium">
        {STYLES}
      </style>
      <section
        {...props}
        data-vibeui-block="gantt-002"
        aria-label={heading}
        className={className}
        style={palette}
      >
        <header data-part="head">
          <h3 data-part="heading">{heading}</h3>
          <p data-part="hint">стрелка — «не раньше, чем закончится»</p>
        </header>

        <div
          data-part="scroll"
          tabIndex={0}
          role="group"
          aria-label={`${heading}: диаграмма, прокручивается вбок`}
        >
          <div data-part="shell">
            <div data-part="names">
              <div style={{ height: 22 }} />
              {tasks.map((task) => (
                <div
                  key={task.id}
                  data-part="name"
                  style={{ height: ROW_HEIGHT }}
                >
                  {task.title}
                  {task.after ? (
                    <small>
                      после: {tasks[rowOf(task.after)]?.title ?? task.after}
                    </small>
                  ) : (
                    <small>старт плана</small>
                  )}
                </div>
              ))}
            </div>

            <div data-part="plot" style={{ width }}>
              <div data-part="scale" style={{ height: 22 }} aria-hidden="true">
                {Array.from({ length: total }, (_, index) => (
                  <span
                    key={index}
                    data-part="day"
                    style={{ width: DAY_WIDTH }}
                  >
                    {dayLabel(index)}
                  </span>
                ))}
              </div>

              <div data-part="canvas" style={{ height }}>
                <svg
                  width={width}
                  height={height}
                  viewBox={`0 0 ${width} ${height}`}
                  aria-hidden="true"
                >
                  <defs>
                    <marker
                      id="vibeui-gantt-002-arrow"
                      markerWidth="6"
                      markerHeight="6"
                      refX="5"
                      refY="3"
                      orient="auto"
                    >
                      <path
                        d="M0,0 L6,3 L0,6 z"
                        fill="var(--vibeui-gantt-002-link)"
                      />
                    </marker>
                  </defs>
                  {links.map((link) => (
                    <polyline
                      key={link.key}
                      points={link.points}
                      fill="none"
                      stroke="var(--vibeui-gantt-002-link)"
                      strokeWidth="1.5"
                      markerEnd="url(#vibeui-gantt-002-arrow)"
                    />
                  ))}
                </svg>

                {tasks.map((task, row) => (
                  <div
                    key={task.id}
                    data-part="bar"
                    data-tone={task.tone ?? "work"}
                    style={{
                      left: task.start * DAY_WIDTH,
                      width: task.days * DAY_WIDTH,
                      top: row * ROW_HEIGHT + (ROW_HEIGHT - BAR_HEIGHT) / 2,
                      height: BAR_HEIGHT,
                    }}
                  >
                    {task.days} дн
                  </div>
                ))}
              </div>
            </div>
          </div>
        </div>

        <details data-part="table">
          <summary>Те же сроки таблицей</summary>
          <table>
            <thead>
              <tr>
                <th scope="col">Задача</th>
                <th scope="col">Начало</th>
                <th scope="col">Конец</th>
                <th scope="col">После</th>
              </tr>
            </thead>
            <tbody>
              {tasks.map((task) => (
                <tr key={task.id}>
                  <th scope="row">{task.title}</th>
                  <td>
                    {new Date(origin + task.start * DAY)
                      .toISOString()
                      .slice(0, 10)}
                  </td>
                  <td>
                    {new Date(origin + (task.start + task.days - 1) * DAY)
                      .toISOString()
                      .slice(0, 10)}
                  </td>
                  <td>
                    {task.after
                      ? (tasks[rowOf(task.after)]?.title ?? task.after)
                      : "—"}
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </details>
      </section>
    </>
  )
}