Generation Canvas
A dotted plane for generative work. Nodes that hold a prompt, its settings or a result, edges that carry light while the result is on its way, and a dock that floats above the plane — three parts in one file, built on the flow edge and the generation overlay.
seed 1 · 16:9 · 2K
"use client";
import { RotateCcw, Sparkles } from "lucide-react";Installation
$ pnpm dlx shadcn@latest add https://motif.sweetretry.com/r/generation-canvas.json
It writes one file, components/ui/motif/generation-canvas.tsx, and brings two registry
items along with it: Flow Edge and
Generation Overlay. It adds no page and no route, and the only package it needs is motion.
What it is made of
The block is the plane and nothing on it. What a node holds is yours, and the controls in the demo above are pieces of this registry that you add only if you use them:
| Part | What it does here |
|---|---|
GenerationCanvas | The frame and the plane: a dotted ground that scrolls, and a dock slot |
CanvasNode | A card at a pixel position, with ports where edges attach |
CanvasEdge | A curve between two ports that carries light while work moves along it |
CanvasMedia | A frame in the requested ratio that flows while it waits |
The demo's settings node is Aspect Ratio Picker, Resolution Segmented and Strength Select, and its dock is a Toolbar. Add them one at a time, with the same command and their own name, when a node of yours needs them.
Usage
A node is a box at a position, and an edge is two points. portOf turns a node into the
point an edge should leave from or arrive at, so moving a node moves its lines with it.
import {
CanvasEdge,
CanvasMedia,
CanvasNode,
GenerationCanvas,
portOf,
} from "@/components/ui/motif/generation-canvas";
const prompt = { x: 24, y: 24, width: 272 };
const image = { x: 368, y: 24, width: 336 };
export function Canvas({ pending, src }: { pending: boolean; src?: string }) {
return (
<GenerationCanvas className="h-[34rem]" width={744} height={400}>
<CanvasEdge
active={pending}
from={portOf(prompt, "out")}
to={portOf(image, "in")}
/>
<CanvasNode {...prompt} ports={["out"]} title="Prompt">
{/* anything */}
</CanvasNode>
<CanvasNode {...image} ports={["in"]} title="Image">
<div className="px-3 pb-3">
<CanvasMedia aspect="16:9" pending={pending} src={src} />
</div>
</CanvasNode>
</GenerationCanvas>
);
}| Part | Prop | What it is |
|---|---|---|
GenerationCanvas | width, height | The plane's size, when the nodes need more room than the frame. It scrolls |
GenerationCanvas | dock | Floats at the bottom centre and stays put while the plane scrolls. Omit it and nothing is drawn |
CanvasNode | x, y, width | Pixel position on the plane. Height is the node's content |
CanvasNode | ports | "in", "out" or both. A side with no edge has no port |
CanvasNode | title | A 40px header row. Omit it and the node is only its content |
CanvasEdge | from, to | Two points, normally portOf(source, "out") and portOf(target, "in") |
CanvasEdge | active | Light is travelling. Off leaves the line at rest |
CanvasMedia | aspect | "16:9" — the string AspectRatioPicker hands back |
CanvasMedia | pending, src | Whether a generation is in flight, and the result once there is one |
The three states of a node
A node waits, finishes or fails, and the line feeding it says which. While work is on its way the edge carries light into the node and the media flows behind it. When the result decodes the flow gives way to a blur-to-focus reveal and the line goes quiet. If the source never loads, the flow gives way to a failure line instead.
a raven on a payphone at dusk, 35mm
CanvasMedia leaves status copy and actions to you. Pass overlay for a retry or a
progress line, and failureLabel for the words along the bottom when the source fails.
Edges run left to right
The light travels in x, so an edge wants its source on the left of its target. A target above or below the source is fine — the curve leaves and arrives level, and bends in the middle — but a target to the left of the source makes the light run backwards through the curve. Lay a canvas out in columns, and the lines take care of themselves.
Ports are measured from the top
portOf puts a port 20px below a node's top edge, the middle of the header row, whether
or not a header is drawn. Nodes can therefore be any height without their edges being
told: an edge only ever needs a node's position and width.
Not in the box
Nothing pans, zooms or drags. A node is a box at a position, and moving it is a change to
x and y in whatever state you keep. If you need a viewport or free dragging, keep
these parts and put your own around them — they do not know what is moving them. And
CanvasEdge is a thin wrapper over Flow Edge, so a graph library
that draws its own edges can use that directly.
Colours
Every colour is a theme token. The plane's dots are border, a node is card, the light
is primary over an idle line in muted-foreground, and a failed source reads as
destructive through the overlay. Restyle the theme and the canvas follows.
Component source
"use client";
import type { CSSProperties, ReactNode } from "react";
import { FlowEdge } from "@/components/ui/motif/flow-edge";
import { GenerationOverlay } from "@/components/ui/motif/generation-overlay";
import { cn } from "@/lib/utils";
/* -- The plane ---------------------------------------------------------------
* A generation canvas is three things on a dotted plane: nodes that hold work,
* edges that say which work feeds which, and a dock that floats above the plane
* rather than riding on it. Each is a part of its own, so a caller can lay out
* two nodes or twenty, leave the dock out, or draw the edges from their own data.
*
* Nothing here pans, zooms or drags. A node is a box at a pixel position, an edge
* is a curve between two points, and the plane scrolls when the nodes outgrow
* it. A graph editor that needs more can keep these parts and put its own
* viewport around them — the parts do not know what is moving them.
* --------------------------------------------------------------------------- */
/** Where a port sits from the top of its node: the middle of the 40px header, so the
* line meets the node at the same height whether or not the header is drawn. */
export const PORT_OFFSET = 20;
export interface CanvasPoint {
x: number;
y: number;
}
export interface CanvasBox {
/** Left edge of the node, in plane pixels. */
x: number;
/** Top edge of the node, in plane pixels. */
y: number;
width: number;
}
/** The point an edge should leave from (`"out"`) or arrive at (`"in"`) on a node. */
export const portOf = (node: CanvasBox, side: "in" | "out"): CanvasPoint => ({
x: side === "out" ? node.x + node.width : node.x,
y: node.y + PORT_OFFSET,
});
const DOTS: CSSProperties = {
backgroundImage: "radial-gradient(var(--border) 1px, transparent 1px)",
backgroundSize: "20px 20px",
};
export interface GenerationCanvasProps {
children?: ReactNode;
className?: string;
/** Floats at the bottom centre, above the plane — a `Toolbar`, usually. It stays
* put while the plane scrolls, and draws nothing when omitted. */
dock?: ReactNode;
/** Height of the plane, when the nodes need more room than the frame gives them. */
height?: number | string;
/** Width of the plane, when the nodes need more room than the frame gives them. */
width?: number | string;
}
/**
* The frame and the plane. The frame takes the size its parent gives it; the plane
* inside is at least that big and scrolls when `width` or `height` ask for more.
*/
export const GenerationCanvas = ({
children,
className,
dock,
height = "100%",
width = "100%",
}: GenerationCanvasProps) => (
<div
className={cn(
"relative isolate overflow-hidden bg-background text-foreground",
className
)}
>
<div className="absolute inset-0 overflow-auto">
<div
className="relative min-h-full min-w-full"
style={{ ...DOTS, height, width }}
>
{children}
</div>
</div>
{dock ? (
<div className="absolute bottom-4 left-1/2 z-10 -translate-x-1/2">
{dock}
</div>
) : null}
</div>
);
export interface CanvasNodeProps extends CanvasBox {
children?: ReactNode;
className?: string;
/** Which ports to draw. A node with no edge on a side has none on it. */
ports?: readonly ("in" | "out")[];
/** Names the node in a header row. Omit it and the node is only its content. */
title?: ReactNode;
}
const PORT =
"absolute size-2 -translate-x-1/2 -translate-y-1/2 rounded-full border border-border bg-card";
/** One box of work on the plane: a card at a pixel position, with ports where edges
* attach. It is a `card` however the plane is themed — the plane is the page. */
export const CanvasNode = ({
children,
className,
ports = [],
title,
width,
x,
y,
}: CanvasNodeProps) => (
<section
className={cn(
"absolute rounded-xl border border-border bg-card text-card-foreground",
className
)}
style={{ left: x, top: y, width }}
>
{title ? (
<header className="flex h-10 items-center px-3 font-medium text-muted-foreground text-xs">
{title}
</header>
) : null}
{children}
{ports.includes("in") ? (
<span
aria-hidden="true"
className={PORT}
style={{ left: 0, top: PORT_OFFSET }}
/>
) : null}
{ports.includes("out") ? (
<span
aria-hidden="true"
className={PORT}
style={{ left: "100%", top: PORT_OFFSET }}
/>
) : null}
</section>
);
export interface CanvasEdgeProps {
/** Whether light is travelling — work is moving along this edge right now. */
active?: boolean;
className?: string;
/** Where the edge leaves, normally `portOf(source, "out")`. */
from: CanvasPoint;
/** Where the edge arrives, normally `portOf(target, "in")`. */
to: CanvasPoint;
}
/** A curve between two ports. It runs left to right — the light travels in x — so
* put the source on the left of the target. */
export const CanvasEdge = ({
active = false,
className,
from,
to,
}: CanvasEdgeProps) => {
const left = Math.min(from.x, to.x);
const top = Math.min(from.y, to.y);
const width = Math.max(Math.abs(to.x - from.x), 1);
const height = Math.max(Math.abs(to.y - from.y), 1);
const startY = from.y <= to.y ? 0 : height;
const endY = from.y <= to.y ? height : 0;
return (
<div
aria-hidden="true"
className="pointer-events-none absolute"
style={{ height, left, top, width }}
>
<FlowEdge
active={active}
className={cn("text-primary", className)}
d={`M 0 ${startY} C ${width / 2} ${startY} ${width / 2} ${endY} ${width} ${endY}`}
glow={2}
strokeWidth={2}
style={{ "--edge-track": "var(--muted-foreground)" } as CSSProperties}
viewBox={`0 0 ${width} ${height}`}
/>
</div>
);
};
export interface CanvasMediaProps {
alt?: string;
/** Aspect ratio as `"16:9"` — the same string `AspectRatioPicker` hands back. */
aspect?: string;
className?: string;
/** Shown along the bottom when `src` never decodes. */
failureLabel?: string;
/** Status copy and actions drawn over the media — a retry, a progress line. */
overlay?: ReactNode;
/** Whether a generation is in flight. */
pending: boolean;
/** Echoed along the bottom while pending. */
prompt?: string | null;
/** Varies the flow's motion, so concurrent nodes do not move in step. */
seed?: string | number;
/** The result. Omit it while nothing exists yet. */
src?: string;
}
/** The media inside a node: a frame in the requested ratio that flows while it waits
* and reveals the result when it decodes. */
export const CanvasMedia = ({
alt = "Generated result",
aspect = "16:9",
className,
failureLabel,
overlay,
pending,
prompt,
seed,
src,
}: CanvasMediaProps) => (
<div
className={cn(
"relative w-full overflow-hidden rounded-lg border border-border",
className
)}
style={{ aspectRatio: aspect.replace(":", " / ") }}
>
<GenerationOverlay
failureLabel={failureLabel}
mediaSrc={src}
overlay={overlay}
pending={pending}
prompt={prompt}
seed={seed}
>
{src ? (
<img alt={alt} className="h-full w-full object-cover" src={src} />
) : null}
</GenerationOverlay>
</div>
);