Charts

Waterfall Flow

A waterfall: how the opening balance became the closing one. Inflows and outflows float on the running total, the totals stand on zero, and connectors hold the level.

  • chart
  • waterfall
  • finance
  • svg

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/chart-018?lang=en

Cash movement this quarter
  • приход
  • расход
  • итог

Единица измерения: thousands

Cash movement this quarter, thousands
Начало420накопленный итог 420
Продажи260накопленный итог 680
Продления140накопленный итог 820
Возвраты-90накопленный итог 730
Расходы-215накопленный итог 515
Итог515накопленный итог 515
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 "chart-018" (Waterfall Flow) 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/chart-018.json

Registry item: https://vibeui.ru/r/chart-018.json
Installs to: components/vibeui/chart-018.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 waterfall: how the opening balance became the closing one. Inflows and outflows float on the running total, the totals stand on zero, and connectors hold the level.

A waterfall chart: total bars stand on zero, inflow and outflow steps float on the running total, dashed connectors join the levels, the sign of the value colours the bar, and the values are printed above the bars. The data is mirrored in a hidden table with the running total. Zero dependencies, one file, no client JS.

## 3. How to use it
import { Chart018 } from "@/components/vibeui/chart-018"

<Chart018
  title="Cash movement this quarter"
  unit="thousands"
  steps={[{ label: "Opening", value: 420, total: true }, { label: "Sales", value: 260 }]}
/>

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-chart-018-* palette — do not swap it for your theme tokens (bg-card, --chart-1 and the like)
- deriving the colour from the sign of the value: an outflow painted green will eventually mislead
- the dashed connectors between steps — without them the level between bars is lost
- total bars measured from zero: they anchor the whole construction, they are not another change
- the «+» sign on inflows: without it plus and minus differ by colour alone
- the hidden table with the running total and aria-hidden on the svg — the data must be readable as text

## 6. You may change
- the steps array: label, value and the total flag on the anchor bars
- title — the chart heading
- unit — the unit named in the caption under the chart
- the accent through the accent prop — it colours the total bars
- the legend of inflows, outflows and totals

## 7. Rules
- A negative running total is not drawn: the scale runs from zero upwards.
- The total flag must sit on the first and the last step — otherwise the construction floats.
- Beyond eight steps the labels under the bars start colliding.
- 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/chart-018.json
https://vibeui.ru/r/chart-018.json

Компонент самодостаточен: один файл, без зависимостей, собственная палитра в локальных переменных --vibeui-chart-018-*. Серверный: хуков нет. Накопленная сумма считается заранее: каждому столбцу нужны и низ, и верх, поэтому один проход по шагам превращает значения в пары от-до. Столбцы с флагом total рисуются от нуля, остальные — от предыдущего уровня; цвет берётся из знака значения, а не из данных. Пунктирные перемычки соединяют вершину шага с началом следующего. Данные продублированы визуально скрытой таблицей с накопленным итогом.

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 Chart018Step = {
  label: string
  value: number
  total?: boolean
}

export type Chart018Props = Omit<
  ComponentPropsWithoutRef<"figure">,
  "children" | "title"
> & {
  title?: string
  steps?: Chart018Step[]
  unit?: string
  accent?: string
}

// Идея компонента: waterfall — как из начального остатка получился конечный.
// Столбцы приходов и расходов висят в воздухе на накопленной сумме, итоговые
// стоят на нуле, а пунктирные перемычки не дают потерять уровень между
// шагами. Знак значения красит столбец, поэтому расход нельзя сделать зелёным.
const STYLES = `
:where([data-vibeui-block="chart-018"]){
--vibeui-chart-018-bg:oklch(1 0 0);
--vibeui-chart-018-fg:oklch(0.22 0.014 265);
--vibeui-chart-018-muted:oklch(0.55 0.014 265);
--vibeui-chart-018-border:oklch(0.91 0.006 265);
--vibeui-chart-018-grid:oklch(0.94 0.005 265);
--vibeui-chart-018-up:oklch(0.62 0.13 155);
--vibeui-chart-018-down:oklch(0.62 0.16 25);
--vibeui-chart-018-total:oklch(0.42 0.03 265);
--vibeui-chart-018-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="chart-018"]{
display:flex;flex-direction:column;gap:0.5rem;
width:100%;max-width:32rem;box-sizing:border-box;margin:0;padding:0.875rem;
background:var(--vibeui-chart-018-bg);
border:1px solid var(--vibeui-chart-018-border);border-radius:0.875rem;
color:var(--vibeui-chart-018-fg);font-family:var(--vibeui-chart-018-font);
}
[data-vibeui-block="chart-018"] [data-part="title"]{margin:0;font-size:0.875rem;font-weight:650}
[data-vibeui-block="chart-018"] svg{display:block;width:100%;height:auto}
[data-vibeui-block="chart-018"] [data-part="grid"]{stroke:var(--vibeui-chart-018-grid);stroke-width:1}
[data-vibeui-block="chart-018"] [data-part="bar"][data-kind="up"]{fill:var(--vibeui-chart-018-up)}
[data-vibeui-block="chart-018"] [data-part="bar"][data-kind="down"]{fill:var(--vibeui-chart-018-down)}
[data-vibeui-block="chart-018"] [data-part="bar"][data-kind="total"]{fill:var(--vibeui-chart-018-total)}
[data-vibeui-block="chart-018"] [data-part="link"]{
stroke:var(--vibeui-chart-018-muted);stroke-width:1;stroke-dasharray:3 3;
}
[data-vibeui-block="chart-018"] [data-part="value"]{
fill:var(--vibeui-chart-018-fg);font-size:8.5px;font-weight:600;text-anchor:middle;
font-variant-numeric:tabular-nums;
}
[data-vibeui-block="chart-018"] [data-part="name"]{
fill:var(--vibeui-chart-018-muted);font-size:8px;text-anchor:middle;
}
[data-vibeui-block="chart-018"] [data-part="key"]{
display:flex;flex-wrap:wrap;gap:0.25rem 0.875rem;margin:0;padding:0;list-style:none;
font-size:0.6875rem;color:var(--vibeui-chart-018-muted);
}
[data-vibeui-block="chart-018"] [data-part="key"] li{display:flex;align-items:center;gap:0.375rem}
[data-vibeui-block="chart-018"] [data-part="chip"]{width:0.625rem;height:0.625rem;border-radius:2px}
[data-vibeui-block="chart-018"] [data-part="chip"][data-kind="up"]{background:var(--vibeui-chart-018-up)}
[data-vibeui-block="chart-018"] [data-part="chip"][data-kind="down"]{background:var(--vibeui-chart-018-down)}
[data-vibeui-block="chart-018"] [data-part="chip"][data-kind="total"]{background:var(--vibeui-chart-018-total)}
[data-vibeui-block="chart-018"] [data-part="unit"]{margin:0;font-size:0.75rem;color:var(--vibeui-chart-018-muted)}
[data-vibeui-block="chart-018"] [data-part="data"]{
position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;
clip-path:inset(50%);white-space:nowrap;border:0;
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="chart-018"] *{animation:none!important;transition:none!important}}
`

const LEFT = 8
const RIGHT = 332
const TOP = 24
const BASE = 126

const DEFAULT_STEPS: Chart018Step[] = [
  { label: "Начало", value: 420, total: true },
  { label: "Продажи", value: 260 },
  { label: "Продления", value: 140 },
  { label: "Возвраты", value: -90 },
  { label: "Расходы", value: -215 },
  { label: "Итог", value: 515, total: true },
]

/**
 * Waterfall: приходы и расходы между двумя итогами.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Chart018({
  title = "Движение денег за квартал",
  steps = DEFAULT_STEPS,
  unit = "тысяч рублей",
  accent,
  className,
  style,
  ...props
}: Chart018Props) {
  // Накопленная сумма считается заранее: каждому столбцу нужны и низ, и верх,
  // а не только значение. Список собирается копированием, чтобы во время
  // отрисовки не мутировался внешний счётчик.
  const bars = steps.reduce<
    { step: Chart018Step; from: number; to: number; kind: string }[]
  >((done, step) => {
    const previous = done.length > 0 ? done[done.length - 1].to : 0
    const from = step.total ? 0 : previous
    const to = step.total ? step.value : previous + step.value

    return [
      ...done,
      {
        step,
        from,
        to,
        kind: step.total ? "total" : step.value >= 0 ? "up" : "down",
      },
    ]
  }, [])

  const ceiling = Math.max(...bars.map((bar) => Math.max(bar.from, bar.to)), 1)
  const band = (RIGHT - LEFT) / Math.max(bars.length, 1)
  const width = Math.min(band * 0.58, 40)
  const y = (value: number) => BASE - (value / ceiling) * (BASE - TOP)

  const palette = {
    ...(accent ? { "--vibeui-chart-018-total": accent } : null),
    ...style,
  } as CSSProperties

  return (
    <>
      <style href="vibeui-chart-018" precedence="medium">
        {STYLES}
      </style>
      <figure
        {...props}
        data-vibeui-block="chart-018"
        className={className}
        style={palette}
      >
        <figcaption data-part="title">{title}</figcaption>
        <svg viewBox="0 0 340 150" aria-hidden="true" focusable="false">
          <line data-part="grid" x1={LEFT} y1={BASE} x2={RIGHT} y2={BASE} />
          {bars.map((bar, index) => {
            const x = LEFT + band * index + (band - width) / 2
            const top = Math.min(y(bar.from), y(bar.to))
            const height = Math.max(Math.abs(y(bar.from) - y(bar.to)), 2)
            const next = bars[index + 1]

            return (
              <g key={bar.step.label}>
                <rect
                  data-part="bar"
                  data-kind={bar.kind}
                  x={x}
                  y={top}
                  width={width}
                  height={height}
                  rx={2}
                />
                <text data-part="value" x={x + width / 2} y={top - 5}>
                  {bar.step.total
                    ? bar.step.value
                    : `${bar.step.value > 0 ? "+" : ""}${bar.step.value}`}
                </text>
                <text data-part="name" x={x + width / 2} y={BASE + 14}>
                  {bar.step.label}
                </text>
                {next && !next.step.total ? (
                  <line
                    data-part="link"
                    x1={x + width}
                    y1={y(bar.to)}
                    x2={x + band}
                    y2={y(bar.to)}
                  />
                ) : null}
              </g>
            )
          })}
        </svg>
        <ul data-part="key">
          <li>
            <span data-part="chip" data-kind="up" aria-hidden="true" />
            приход
          </li>
          <li>
            <span data-part="chip" data-kind="down" aria-hidden="true" />
            расход
          </li>
          <li>
            <span data-part="chip" data-kind="total" aria-hidden="true" />
            итог
          </li>
        </ul>
        <p data-part="unit">Единица измерения: {unit}</p>
        <table data-part="data">
          <caption>
            {title}, {unit}
          </caption>
          <tbody>
            {bars.map((bar) => (
              <tr key={bar.step.label}>
                <th scope="row">{bar.step.label}</th>
                <td>{bar.step.value}</td>
                <td>накопленный итог {bar.to}</td>
              </tr>
            ))}
          </tbody>
        </table>
      </figure>
    </>
  )
}