shadimage
easeui
componentflowchartsource: ok · refreshed

Flowchart

A sequence of steps on a dotted canvas, connected by curves that measure the actual cards and follow as you drag one. Click a step to light up its connectors, or edit an if/else step's chips with real dropdowns.

npx shadcn add "https://easeui.dev/r/flowchart.json"
free

Where it goes

onboarding

Interaction

dragdropdown

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
iconslucide-react
animationmotion

Files4 · source cached 10/8/2026, 10:11:53 AM

components/motion/flowchart.tsx
"use client";
// easeui.dev/components/agents/flowchart

import { Check, ChevronDown } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import {
  type MouseEvent as ReactMouseEvent,
  type PointerEvent as ReactPointerEvent,
  type ReactNode,
  useEffect,
  useLayoutEffect,
  useRef,
  useState,
} from "react";
import { Badge, type BadgeVariant } from "@/components/motion/badge";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";

/** One dropdown chip inside a condition clause: its current value and the choices it offers. */
export interface ConditionField {
  value: string;
  options: string[];
}

export interface ConditionClause {
  id: string;
  /** "if" for the first clause in a step, "and" for every clause after it. */
  connector: "if" | "and";
  /** The record a comparison reads from, shown as a small chip, e.g. "order". */
  source: string;
  property: ConditionField;
  value: ConditionField;
}

export interface FlowStep {
  id: string;
  /** Small label above the card, such as "Trigger" or "If / Else". */
  kind?: string;
  /** Color treatment for the kind pill and icon tint. Default "accent". */
  kindVariant?: BadgeVariant;
  title?: string;
  description?: string;
  icon?: ReactNode;
  /** Renders an editable if/else condition list instead of the title and description. */
  condition?: ConditionClause[];
  /** Card width in px. Default 256, or 340 for a condition card. */
  width?: number;
}

export interface FlowchartProps {
  /** Stacked top to bottom by default; drag a card anywhere on the canvas. */
  steps: FlowStep[];
  className?: string;
}

const GAP = 64;
const PAD = 24;
const EST_HEIGHT = 76;
const CARD_WIDTH = 256;
const CONDITION_WIDTH = 340;
/** Pointer movement, in px, before a press counts as a drag rather than a click. */
const DRAG_THRESHOLD = 3;
const OPEN = { duration: 0.15, ease: EASE_OUT } as const;
const CLOSE = { duration: 0.1, ease: EASE_OUT } as const;

type Offset = { dx: number; dy: number };
type DragState = { id: string; startX: number; startY: number; baseDx: number; baseDy: number; moved: boolean };

const ICON_TINT: Record<BadgeVariant, string> = {
  neutral: "bg-muted text-muted-foreground",
  accent: "bg-accent/10 text-accent",
  success: "bg-success/15 text-success",
  warning: "bg-warning/15 text-warning",
  destructive: "bg-destructive/15 text-destructive",
};

/** A small chip that opens an upward dropdown, used for the property and value in a condition clause. */
function ChipSelect({
  value,
  options,
  align = "left",
  onChange,
}: {
  value: string;
  options: string[];
  align?: "left" | "right";
  onChange: (value: string) => void;
}) {
  const [open, setOpen] = useState(false);
  const rootRef = useRef<HTMLDivElement>(null);
  const reduce = useReducedMotion();

  useEffect(() => {
    if (!open) return;
    const onPointerDown = (event: PointerEvent) => {
      if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
    };
    window.addEventListener("pointerdown", onPointerDown);
    return () => window.removeEventListener("pointerdown", onPointerDown);
  }, [open]);

  return (
    <div ref={rootRef} data-no-drag className="relative inline-flex min-w-0">
      <button
        type="button"
        aria-haspopup="listbox"
        aria-expanded={open}
        onClick={() => setOpen((current) => !current)}
        onKeyDown={(event) => {
          if (event.key === "Escape") setOpen(false);
        }}
        className={cn(
          "inline-flex h-6 min-w-0 items-center gap-1 rounded-md px-1.5 text-xs font-medium text-foreground outline-none transition-colors duration-150",
          open ? "bg-muted" : "bg-card hover:bg-muted",
        )}
      >
        <span className="min-w-0 truncate">{value}</span>
        <ChevronDown
          aria-hidden="true"
          className={cn("h-3 w-3 shrink-0 text-muted-foreground transition-transform duration-150", open && "rotate-180")}
        />
      </button>
      <motion.div
        role="listbox"
        aria-hidden={!open}
        inert={!open}
        initial={false}
        animate={reduce ? { opacity: open ? 1 : 0 } : { opacity: open ? 1 : 0, scale: open ? 1 : 0.97 }}
        transition={reduce ? { duration: 0 } : open ? OPEN : CLOSE}
        style={{
          transformOrigin: `bottom ${align === "right" ? "right" : "left"}`,
          pointerEvents: open ? "auto" : "none",
        }}
        className={cn(
          "absolute bottom-full z-20 mb-1.5 flex min-w-36 max-w-[calc(100vw-2rem)] flex-col gap-0.5 rounded-lg bg-background p-1",
          "shadow-[0_0_0_1px_var(--border-strong),0_12px_24px_-12px_rgb(0_0_0/0.3)]",
          align === "right" ? "right-0" : "left-0",
        )}
      >
        {options.map((option) => (
          <button
            key={option}
            type="button"
            role="option"
            aria-selected={option === value}
            onClick={() => {
              onChange(option);
              setOpen(false);
            }}
            className={cn(
              "flex min-h-8 w-full touch-manipulation items-center justify-between gap-2 rounded-md px-2 text-left text-xs outline-none transition-colors duration-150",
              option === value ? "text-foreground" : "text-muted-foreground",
              "hover:bg-muted hover:text-foreground",
            )}
          >
            <span className="truncate">{option}</span>
            {option === value ? <Check aria-hidden="true" className="h-3 w-3 shrink-0" /> : null}
          </button>
        ))}
      </motion.div>
    </div>
  );
}

/** The if/else editor body: one row per clause, each a "connector, source, property is value" line. */
function ConditionBody({ clauses }: { clauses: ConditionClause[] }) {
  const [selection, setSelection] = useState<Record<string, { property: string; value: string }>>(() =>
    Object.fromEntries(clauses.map((clause) => [clause.id, { property: clause.property.value, value: clause.value.value }])),
  );

  return (
    <div className="flex flex-col gap-2 p-3">
      {clauses.map((clause) => (
        <div key={clause.id} className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1.5">
          <span className="w-7 shrink-0 text-xs text-muted-foreground">{clause.connector === "if" ? "If" : "and"}</span>
          <span className="inline-flex h-6 shrink-0 items-center rounded-md bg-card px-1.5 text-xs font-medium text-foreground shadow-[0_0_0_1px_var(--border)]">
            {clause.source}
          </span>
          <ChipSelect
            value={selection[clause.id].property}
            options={clause.property.options}
            onChange={(next) => setSelection((current) => ({ ...current, [clause.id]: { ...current[clause.id], property: next } }))}
          />
          <span className="text-xs text-muted-foreground">is</span>
          <ChipSelect
            value={selection[clause.id].value}
            options={clause.value.options}
            align="right"
            onChange={(next) => setSelection((current) => ({ ...current, [clause.id]: { ...current[clause.id], value: next } }))}
          />
        </div>
      ))}
    </div>
  );
}

/**
 * A sequence of steps on a dotted canvas, connected by curves that measure
 * the actual rendered cards. Each card can be dragged anywhere on the
 * canvas and the connector follows it live; click a card (without dragging
 * it) to light up the connectors on either side of it. A step can also
 * render as an if/else condition editor, with its property and value chips
 * opening real dropdowns, instead of the usual title and description.
 */
export function Flowchart({ steps, className }: FlowchartProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const cardRefs = useRef<(HTMLElement | null)[]>([]);
  const [containerWidth, setContainerWidth] = useState(0);
  const [heights, setHeights] = useState<number[]>(() => steps.map(() => EST_HEIGHT));
  const [offsets, setOffsets] = useState<Record<string, Offset>>({});
  const [selected, setSelected] = useState<string | null>(null);
  const dragRef = useRef<DragState | null>(null);

  // steps.length is never read directly, but a changed step count means new
  // cards to observe, so the effect needs to re-run and re-attach.
  // biome-ignore lint/correctness/useExhaustiveDependencies: steps.length is intentional, see comment above.
  useLayoutEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    const measure = () => {
      setContainerWidth(container.clientWidth);
      setHeights((previous) =>
        cardRefs.current.map((card, index) => {
          const measured = card?.offsetHeight;
          return measured && measured > 0 ? measured : (previous[index] ?? EST_HEIGHT);
        }),
      );
    };

    measure();
    const observer = new ResizeObserver(measure);
    observer.observe(container);
    for (const card of cardRefs.current) {
      if (card) observer.observe(card);
    }
    return () => observer.disconnect();
  }, [steps.length]);

  const widths = steps.map((step) => {
    const base = step.width ?? (step.condition ? CONDITION_WIDTH : CARD_WIDTH);
    return containerWidth > 0 ? Math.min(base, containerWidth * 0.92) : base;
  });

  // Where each card sits before any dragging: stacked with a gap for the connector to arc through.
  const baseTops: number[] = [];
  steps.forEach((_, index) => {
    baseTops[index] = index === 0 ? PAD : baseTops[index - 1] + heights[index - 1] + GAP;
  });
  const canvasHeight = (baseTops.at(-1) ?? PAD) + (heights.at(-1) ?? EST_HEIGHT) + PAD;
  const centerX = containerWidth / 2;

  const positionOf = (index: number) => {
    const offset = offsets[steps[index].id];
    return { x: centerX + (offset?.dx ?? 0), top: baseTops[index] + (offset?.dy ?? 0) };
  };

  const onPointerDown = (id: string) => (event: ReactPointerEvent<HTMLDivElement>) => {
    // A press on a chip's own dropdown should open it, not drag the card.
    if ((event.target as Element).closest("[data-no-drag]")) return;
    const offset = offsets[id];
    dragRef.current = {
      id,
      startX: event.clientX,
      startY: event.clientY,
      baseDx: offset?.dx ?? 0,
      baseDy: offset?.dy ?? 0,
      moved: false,
    };
    event.currentTarget.setPointerCapture(event.pointerId);
  };

  const onPointerMove = (id: string, index: number) => (event: ReactPointerEvent<HTMLDivElement>) => {
    const drag = dragRef.current;
    if (!drag || drag.id !== id) return;
    const dx = drag.baseDx + event.clientX - drag.startX;
    const dy = drag.baseDy + event.clientY - drag.startY;
    if (!drag.moved && Math.hypot(dx - drag.baseDx, dy - drag.baseDy) < DRAG_THRESHOLD) return;
    drag.moved = true;

    // Keep the card inside the canvas.
    const halfWidth = widths[index] / 2;
    const clampedDx = Math.min(Math.max(dx, halfWidth + PAD - centerX), containerWidth - halfWidth - PAD - centerX);
    const height = heights[index] ?? EST_HEIGHT;
    const clampedDy = Math.min(
      Math.max(dy, PAD - baseTops[index]),
      canvasHeight - PAD - height - baseTops[index],
    );
    setOffsets((current) => ({ ...current, [id]: { dx: clampedDx, dy: clampedDy } }));
  };

  const onPointerUp = (id: string) => () => {
    const drag = dragRef.current;
    if (drag?.id !== id) return;
    // A real drag shouldn't also toggle selection on release, but the click
    // still needs to see `moved`, so clear the ref one tick later.
    if (drag.moved) setTimeout(() => { dragRef.current = null; }, 0);
    else dragRef.current = null;
  };

  const toggleSelected = (id: string) => setSelected((current) => (current === id ? null : id));

  const onCardClick = (id: string) => (event: ReactMouseEvent<HTMLElement>) => {
    if ((event.target as Element).closest("[data-no-drag]")) return;
    if (dragRef.current?.moved) return;
    toggleSelected(id);
  };

  return (
    <div
      ref={containerRef}
      className={cn(
        // No overflow-hidden here: border-radius already clips the dotted
        // background on its own, and a condition card's dropdown needs to
        // spill past the canvas's tightly-fit height without being clipped.
        "relative w-full touch-none select-none rounded-2xl bg-card shadow-[0_0_0_1px_var(--border)]",
        className,
      )}
      style={{
        height: canvasHeight,
        backgroundImage: "radial-gradient(var(--border-strong) 1px, transparent 1.25px)",
        backgroundSize: "20px 20px",
      }}
    >
      <svg aria-hidden="true" className="pointer-events-none absolute inset-0 h-full w-full">
        {steps.slice(1).map((step, index) => {
          const from = positionOf(index);
          const to = positionOf(index + 1);
          const fromBottom = from.top + (heights[index] ?? EST_HEIGHT);
          const lit = selected === steps[index].id || selected === step.id;
          const bend = Math.min(Math.max((to.top - fromBottom) * 0.6, 16), 48);
          return (
            <path
              key={step.id}
              d={`M ${from.x} ${fromBottom} C ${from.x} ${fromBottom + bend}, ${to.x} ${to.top - bend}, ${to.x} ${to.top}`}
              fill="none"
              stroke={lit ? "var(--accent)" : "var(--border-strong)"}
              strokeWidth={1.5}
              className="transition-[stroke] duration-150"
            />
          );
        })}
      </svg>

      {steps.map((step, index) => {
        const { x, top } = positionOf(index);
        const active = selected === step.id;
        const variant = step.kindVariant ?? "accent";
        return (
          <div
            key={step.id}
            ref={(el) => {
              cardRefs.current[index] = el;
            }}
            onPointerDown={onPointerDown(step.id)}
            onPointerMove={onPointerMove(step.id, index)}
            onPointerUp={onPointerUp(step.id)}
            className="absolute flex -translate-x-1/2 cursor-grab touch-none flex-col items-center gap-1.5 active:cursor-grabbing"
            style={{ left: x, top, width: widths[index], zIndex: dragRef.current?.id === step.id ? 2 : 1 }}
          >
            {step.kind ? <Badge variant={variant}>{step.kind}</Badge> : null}
            {step.condition ? (
              // biome-ignore lint/a11y/useSemanticElements: a <button> can't nest the chip buttons inside it.
              <div
                role="button"
                tabIndex={0}
                onClick={onCardClick(step.id)}
                onKeyDown={(event) => {
                  if (event.target === event.currentTarget && (event.key === "Enter" || event.key === " ")) {
                    event.preventDefault();
                    toggleSelected(step.id);
                  }
                }}
                aria-pressed={active}
                className={cn(
                  "w-full cursor-pointer rounded-2xl bg-background text-left outline-none transition-shadow duration-150",
                  active
                    ? "shadow-[0_0_0_1.5px_var(--accent)]"
                    : "shadow-[0_0_0_1px_var(--border)] hover:shadow-[0_0_0_1px_var(--border-strong)]",
                )}
              >
                <ConditionBody clauses={step.condition} />
              </div>
            ) : (
              <button
                type="button"
                onClick={onCardClick(step.id)}
                aria-pressed={active}
                className={cn(
                  "flex w-full items-center gap-3 rounded-2xl bg-background p-3 text-left outline-none transition-shadow duration-150",
                  active
                    ? "shadow-[0_0_0_1.5px_var(--accent)]"
                    : "shadow-[0_0_0_1px_var(--border)] hover:shadow-[0_0_0_1px_var(--border-strong)]",
                )}
              >
                {step.icon ? (
                  <span className={cn("flex h-9 w-9 shrink-0 items-center justify-center rounded-lg", ICON_TINT[variant])}>
                    {step.icon}
                  </span>
                ) : null}
                <span className="min-w-0">
                  <span className="block truncate text-sm font-semibold text-foreground">{step.title}</span>
                  {step.description ? (
                    <span className="mt-0.5 block text-pretty text-xs leading-snug text-muted-foreground">
                      {step.description}
                    </span>
                  ) : null}
                </span>
              </button>
            )}
          </div>
        );
      })}
    </div>
  );
}
components/motion/badge.tsx
import type { HTMLAttributes } from "react";
import { cn } from "@/lib/utils";

export type BadgeVariant = "neutral" | "accent" | "success" | "warning" | "destructive";

export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
  /** Color treatment. Default "neutral". */
  variant?: BadgeVariant;
}

const VARIANT_CLASS: Record<BadgeVariant, string> = {
  neutral: "bg-muted text-muted-foreground shadow-[0_0_0_1px_var(--border)]",
  accent: "bg-accent/15 text-accent",
  success: "bg-success/15 text-success",
  warning: "bg-warning/15 text-warning",
  destructive: "bg-destructive/15 text-destructive",
};

/** A small status pill. Crossfades color when its variant changes, such as pending to success. */
export function Badge({ variant = "neutral", className, ...props }: BadgeProps) {
  return (
    <span
      className={cn(
        "inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium transition-colors duration-150 ease-out",
        VARIANT_CLASS[variant],
        className,
      )}
      {...props}
    />
  );
}
lib/ease.ts
// Shared motion tokens. Micro-interactions run 100 to 150ms, standard UI
// 150 to 250ms, and panels up to 300ms.
// Easing curves mirror the CSS custom properties in globals.css.

/** ease-out-quint. Fast start that settles quickly. Entrances, exits, feedback. */
export const EASE_OUT = [0.23, 1, 0.32, 1] as const;
/** ease-in-out-cubic. Elements already on screen moving to a new spot. */
export const EASE_IN_OUT = [0.645, 0.045, 0.355, 1] as const;
/** Sheet and drawer glide. */
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;

/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.23, 1, 0.32, 1)";

// Springs are described by duration and bounce, which is easier to reason about
// than stiffness and damping. Bounce stays at zero for product UI.

/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
  type: "spring",
  duration: 0.15,
  bounce: 0,
} as const;

/** Content swaps, label and icon slots trading places inside a control. */
export const SPRING_SWAP = {
  type: "spring",
  duration: 0.2,
  bounce: 0,
} as const;

/** Overlay panel entrances, modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
  type: "spring",
  duration: 0.25,
  bounce: 0,
} as const;

/** Shared-layout glides, pills and indicators moving between positions. */
export const SPRING_LAYOUT = {
  type: "spring",
  duration: 0.22,
  bounce: 0,
} as const;

/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt). */
export const SPRING_MOUSE = {
  stiffness: 320,
  damping: 26,
  mass: 0.3,
} as const;

/** Dragged handles and fills (sliders). Critically damped `useSpring` config,
 * so the value follows the pointer closely and never rebounds off an end. */
export const SPRING_GLIDE = {
  stiffness: 700,
  damping: 50,
  mass: 0.5,
} as const;
lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

More from easeui

Browse all components in this library →