Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Flowchart

New

Two workflow steps on a dotted board, a trigger and an If / Else, joined by a curve that follows them as they are dragged.

flowchart/flowchart.tsx
Live · interactive
Trigger
If / Else
Iforderis
andorderis
Drag a step to move it, or use the arrow keys.

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

When to use

  • Showing how an automation runs: a trigger, then the step that decides what happens next.
  • Rules that read best as a sentence: “If order flavor is Rocky Road, and order topping is …”.
  • Conditions picked from short, known lists (fields and their values), each a chip with a menu.
  • A builder where people arrange the steps themselves, by dragging them or with the arrow keys.

When not to use

  • Long workflows with many branches and loops. Use a full canvas with zoom, pan and auto layout.
  • A single rule with no trigger to show. A filter row or a form says it in less room.
  • Values typed freely (amounts, dates, text). The chips only pick from lists; use real inputs.
  • A read-only diagram that nobody edits. Draw it as a static figure.

You can add this component with the shadcn CLI or by hand:

Into components/flowchart of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/flowchart.json

Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

Usage

order-rule.tsx
import { Flowchart, type Condition } from './flowchart';export function OrderRule({ rule, onSave }: { rule: Condition[]; onSave: (rule: Condition[]) => void }) {  return (    <Flowchart      trigger={{ tag: 'Trigger', title: 'New order created', description: 'Trigger when a new order is created' }}      defaultConditions={rule}      onConditionsChange={onSave}      autoplay={false}    />  );}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

A trigger card, “New order created”, joined by a soft curve to an If / Else step whose conditions read as a sentence of chips: if order flavor is Rocky Road, and order topping is… Drag either step about the board and the curve follows in the same frame; select the trigger and its ring and the curve turn blue. Every field and value chip opens a small menu over itself, with one highlight gliding between the options.

  • Each step is placed by its left (its middle, from the board’s centre) and its top. A press anywhere on a step but its chips becomes a drag after 3 px, and only then captures the pointer, so a press that stays put still clicks the card under it. Steps stay 8 px inside the board, and the one last moved comes to the front.
  • The curve runs from the middle of the trigger card’s bottom edge to the middle of the If / Else card’s top edge: a cubic Bézier whose two handles stand off vertically by 0.55 of the drop. It is measured from the cards themselves in a layout effect, so it is redrawn before the frame that moved them is painted.
  • Selecting the trigger swaps its shadow for a 1.5 px accent ring, and the curve’s stroke eases to the accent in 150 ms. Hovering a card raises its shadow in 150 ms; every shadow is written with the same nine layers, so the browser can ease between them.
  • A menu pops in over its chip in 180 ms (a 0.95 scale and a fade, on cubic-bezier(0.23, 1, 0.32, 1)) from the chip’s corner: the left one, or the right one when it would pass the card’s edge; it opens under the chip when the board has no room above. It is placed in a layout effect, before it is ever painted.
  • One highlight glides between the options (220 ms on its top) under the pointer or the arrow keys, and fades away when the pointer leaves the menu. The chosen option keeps a check.
  • A condition’s value sits on its row when it fits, and otherwise drops to a line of its own, lined up under the subject chip; on a narrow board the card wraps and the board grows to keep 24 px under it.
  • While on screen and untouched, a demo pointer selects the trigger, drags it off and back, then picks another flavour and topping and puts the first ones back. Any input of the reader’s hands it over.
  • Every colour is a light-dark() token on the root, so the board follows the page’s color-scheme or the colorScheme prop. The two step hues, violet and amber, are mixed into each scheme’s surface with color-mix(), so their tags and icon tile are tuned for both.
PropTypeDefaultDescription
trigger{ tag: string; title: string; description: string }New order createdThe first step: its tag, its title and the line under it.
branchTagstring'If / Else'The tag over the conditions.
defaultConditionsCondition[]flavour and toppingThe rows it starts with: { id, joiner, subject, field, fields, value, values: { label, note?, color? }[], valuesWidth? }.
onConditionsChange(conditions: Condition[]) => void—Called with every condition after a field or a value is picked.
defaultMenuFlowchartMenu | null—A condition’s menu open from the start: row, slot ('field' or 'value') and active, the option under the highlight. It closes as usual. Setting it turns the demo off.
defaultSelectedbooleanfalseWhether the trigger starts selected.
onSelectedChange(selected: boolean) => void—Called when the trigger is selected or cleared.
draggablebooleantrueThe steps can be dragged about the board, and a focused trigger moved with the arrow keys.
defaultOffsetsPartial<Record<'trigger' | 'branch', { x: number; y: number }>>—How far each step starts from its place, in px. Setting it turns the demo off.
autoplaybooleantrueWhile on screen and untouched, a demo pointer selects and drags the trigger and picks other values.
colorScheme'light' | 'dark' | 'system'—Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
classNamestring—Extra class for the root.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

flowchart.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useCallback,  useEffect,  useId,  useLayoutEffect,  useRef,  useState,  type KeyboardEvent as ReactKeyboardEvent,  type PointerEvent as ReactPointerEvent,} from 'react';import { DemoPointer, runDemo, type Point } from './demo-pointer';import { useAutoplay } from './use-autoplay';import styles from './flowchart.module.css';/* * Flowchart: two steps of a workflow on a dotted board. A trigger ("New order created") is joined * by a curve to an If / Else step whose conditions read as a sentence of chips: "If order flavor * is Rocky Road, and order topping is ...". The field and the value chips open menus. * * - Each step is placed by its left (its middle, from the board's centre) and its top. Pressing a
flowchart.module.css
/* Flowchart (flowchart.tsx): a soft grey stage, and in its middle a board 480 px wide at most,   dotted every 22 px, on which two steps sit: the trigger, a card that can be selected, and the   If / Else step, its conditions in rows of chips. A curve joins them.   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes   the color-scheme it inherits from the page (light unless the page says otherwise), or the one   its colorScheme prop sets (data-color-scheme). Shadows that differ between the schemes are   written once with every layer, the layers a scheme does not use being transparent.   Motion. Hovers ease shadows and fills (150 and 100 ms); a menu pops in from its chip's corner   (180 ms, a 0.95 scale and a fade) and its highlight glides between options (220 ms). Steps move   under the pointer by their left and top, so the curve is redrawn in the same frame. Under   reduced motion all of it is instant. */.root {  /* The stage, the board, a card and the ink in three strengths. */  --canvas: oklch(96.1% 0.002 247.84);  --page: oklch(98.5% 0.001 286.376);  --surface: oklch(100% 0 0);  --ink: oklch(24.7% 0.006 258.361);  --ink-2: oklch(50.6% 0.01 264.477);  --ink-3: oklch(69.5% 0.009 264.505);  /* Hairlines; the stronger one draws the dots and the curve. */  --line: oklch(94.6% 0.003 264.542);
demo-pointer.tsx
'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, glide * the pointer onto a control, press it and call the component's own handler, as a click would. * Stopping the demo (the reader takes over, or it scrolls away) ends the step in progress at once: * its promise rejects, which ends the script right there, and the pointer fades out. * * The pointer is an HTML box around a still drawing: it glides by an animation the compositor * plays, and the press scales a box that keeps a transform at rest, so nothing it does needs a * layout. */export type Point = { x: number; y: number };export type Demo = {  /** Resolves after `ms`. */  wait: (ms: number) => Promise<void>;  /** Glides the pointer onto `target`: its middle, or a point given as shares of its size. */  moveTo: (target: Element | null | undefined, at?: Point) => Promise<void>;  /** Presses and lets go; `action` runs as the pointer goes down. */  press: (action: () => void) => Promise<void>;
use-autoplay.ts
'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) {  const ref = useRef<T>(null);  const [visible, setVisible] = useState(false);  const [reduced, setReduced] = useState(true);  const [taken, setTaken] = useState(false);  const timer = useRef(0);  useEffect(() => {    const element = ref.current;    if (!element) return;    let onScreen = false;    const update = () => setVisible(onScreen && !document.hidden);    const observer = new IntersectionObserver(      ([entry]) => {        onScreen = entry.isIntersecting;
  • The trigger is a toggle button (aria-pressed). Focused, the arrow keys move it 4 px at a time, or a dot’s width (22 px) with Shift; a visually hidden hint says so.
  • Each field and value chip is a button with aria-haspopup="listbox" and a name that says what it holds (“order flavor: Rocky Road”). Enter, Space or the arrows open its menu, a listbox that takes the focus and marks its active option with aria-activedescendant; Home and End jump, Enter or Space picks, Escape closes and returns to the chip, Tab closes and moves on.
  • A press outside an open menu closes it. A choice is announced in a polite live region (“order topping is Hot fudge”).
  • The conditions card is a labelled group; the curve, the grips and the icons are decorative (aria-hidden).
  • Under reduced motion menus appear at once, the highlight and the shadows do not ease, and the demo never plays.