Claude
Skills
Sign in
Back

storyboard-canvas

Included with Lifetime
$97 forever

React Flow canvas preconfigured for storyboarding — custom shot nodes with frame image, description, duration, and camera angle, with a side panel for regeneration. Use this skill when the user says "add storyboard", "storyboard canvas", "shot list canvas", "visual storyboard", or "react flow storyboard".

Image & Video

What this skill does


# Storyboard Canvas

A React Flow canvas tailored for video/film storyboarding. Each shot is a draggable node showing its frame image (or placeholder), description, duration, and camera angle. Clicking a node opens a right-side panel for editing metadata, regenerating the frame with a prompt, and browsing image history.

## Prerequisites

- Next.js app with App Router (no `src/` directory)
- `@xyflow/react` installed (from the `react-flow` skill)
- shadcn/ui installed (from the `add-shadcn` skill)
- `@phosphor-icons/react` installed

## Installation

```bash
bun add @xyflow/react
bun add @phosphor-icons/react
bunx shadcn@latest add button textarea badge scroll-area
```

## What Gets Created

```
components/
└── storyboard/
    ├── storyboard-canvas.tsx
    ├── storyboard-node.tsx
    └── shot-panel.tsx
lib/
└── storyboard/
    └── types.ts
```

## Setup Steps

### Step 1: Create `lib/storyboard/types.ts`

```typescript
import type { Edge, Node } from "@xyflow/react"

export type ShotStatus = "pending" | "generating" | "done" | "error"

export type ShotData = {
  shotNumber: number
  description: string
  cameraAngle: string
  duration: number
  imageUrl?: string
  imageHistory?: string[]
  status: ShotStatus
  notes?: string
}

export type StoryboardShot = Node<ShotData>

export type StoryboardEdge = Edge

export type StoryboardCanvasProps = {
  shots: ShotData[]
  onShotsChange?: (shots: ShotData[]) => void
  onRegenerateShot?: (shotId: string, prompt: string) => Promise<string>
  onExportPdf?: () => void
  onShareLink?: () => void
  className?: string
}
```

### Step 2: Create `components/storyboard/storyboard-node.tsx`

```typescript
"use client"

import type { ShotData } from "@/lib/storyboard/types"
import { Camera } from "@phosphor-icons/react"
import { Handle, Position, type NodeProps } from "@xyflow/react"

export function StoryboardNode({ data, selected }: NodeProps<{ data: ShotData } & Record<string, unknown>>) {
  const shotData = data as unknown as ShotData

  return (
    <div
      className={[
        "w-[300px] rounded-xl bg-white shadow-md border transition-all select-none",
        selected ? "border-blue-500 ring-2 ring-blue-300" : "border-zinc-200",
      ].join(" ")}
    >
      <Handle type="target" position={Position.Left} className="!bg-zinc-400" />

      {/* Header */}
      <div className="flex items-center justify-between px-3 pt-3 pb-1">
        <span className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-500 text-[11px] font-bold text-white">
          {shotData.shotNumber}
        </span>
        <StatusDot status={shotData.status} />
      </div>

      {/* Frame */}
      <div className="mx-3 h-[168px] overflow-hidden rounded-lg bg-zinc-100">
        {shotData.status === "generating" ? (
          <div className="h-full w-full animate-pulse bg-gradient-to-r from-zinc-200 via-zinc-100 to-zinc-200" />
        ) : shotData.imageUrl ? (
          <img
            src={shotData.imageUrl}
            alt={`Shot ${shotData.shotNumber}`}
            className="h-full w-full object-cover"
          />
        ) : (
          <div className="flex h-full w-full flex-col items-center justify-center gap-2 text-zinc-400">
            <Camera size={36} weight="thin" />
            <span className="text-xs">No frame yet</span>
          </div>
        )}
      </div>

      {/* Meta */}
      <div className="px-3 pb-3 pt-2">
        <p className="line-clamp-2 text-[13px] leading-snug text-zinc-700">
          {shotData.description || <span className="italic text-zinc-400">No description</span>}
        </p>
        <div className="mt-2 flex items-center gap-2">
          <span className="rounded-full bg-zinc-100 px-2 py-0.5 text-[11px] font-medium text-zinc-600">
            {shotData.duration}s
          </span>
          <span className="rounded-full bg-blue-50 px-2 py-0.5 text-[11px] font-medium text-blue-600">
            {shotData.cameraAngle}
          </span>
        </div>
      </div>

      <Handle type="source" position={Position.Right} className="!bg-zinc-400" />
    </div>
  )
}

function StatusDot({ status }: { status: ShotData["status"] }) {
  const colors: Record<ShotData["status"], string> = {
    pending: "bg-zinc-300",
    generating: "bg-yellow-400 animate-pulse",
    done: "bg-green-400",
    error: "bg-red-400",
  }
  return <span className={`block h-2.5 w-2.5 rounded-full ${colors[status]}`} />
}
```

### Step 3: Create `components/storyboard/shot-panel.tsx`

```typescript
"use client"

import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import type { ShotData } from "@/lib/storyboard/types"
import { ArrowCounterClockwise, X } from "@phosphor-icons/react"
import { useId, useState } from "react"

type ShotPanelProps = {
  shotId: string
  data: ShotData
  onClose: () => void
  onChange: (updated: Partial<ShotData>) => void
  onRegenerate?: (shotId: string, prompt: string) => Promise<string>
}

export function ShotPanel({ shotId, data, onClose, onChange, onRegenerate }: ShotPanelProps) {
  const [prompt, setPrompt] = useState("")
  const [isRegenerating, setIsRegenerating] = useState(false)
  const historyId = useId()

  async function handleRegenerate() {
    if (!onRegenerate || !prompt.trim()) return
    setIsRegenerating(true)
    try {
      const newUrl = await onRegenerate(shotId, prompt)
      const history = [...(data.imageHistory ?? []), ...(data.imageUrl ? [data.imageUrl] : [])]
      onChange({ imageUrl: newUrl, imageHistory: history.slice(-3), status: "done" })
      setPrompt("")
    } catch {
      onChange({ status: "error" })
    } finally {
      setIsRegenerating(false)
    }
  }

  function restoreFromHistory(url: string) {
    const current = data.imageUrl
    const history = (data.imageHistory ?? []).filter((u) => u !== url)
    if (current) history.push(current)
    onChange({ imageUrl: url, imageHistory: history.slice(-3) })
  }

  return (
    <div className="flex h-full w-[380px] flex-col border-l border-zinc-200 bg-white">
      {/* Header */}
      <div className="flex items-center justify-between border-b border-zinc-100 px-5 py-4">
        <div>
          <p className="text-xs font-medium text-zinc-400 uppercase tracking-wide">Shot {data.shotNumber}</p>
          <h2 className="text-sm font-semibold text-zinc-800">{data.cameraAngle}</h2>
        </div>
        <button
          onClick={onClose}
          className="flex h-7 w-7 items-center justify-center rounded-md text-zinc-400 hover:bg-zinc-100 hover:text-zinc-700 transition-colors"
          aria-label="Close panel"
        >
          <X size={16} />
        </button>
      </div>

      {/* Scrollable body */}
      <div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
        {/* Description */}
        <section>
          <label className="mb-1.5 block text-xs font-medium text-zinc-600">Description</label>
          <Textarea
            value={data.description}
            onChange={(e) => onChange({ description: e.target.value })}
            rows={3}
            className="resize-none text-sm"
            placeholder="Describe the shot..."
          />
        </section>

        {/* Duration stepper */}
        <section>
          <label className="mb-1.5 block text-xs font-medium text-zinc-600">Duration</label>
          <div className="flex items-center gap-3">
            <button
              onClick={() => onChange({ duration: Math.max(1, data.duration - 1) })}
              className="flex h-8 w-8 items-center justify-center rounded-md border border-zinc-200 text-zinc-600 hover:bg-zinc-50 transition-colors font-medium"
            >
              −
            </button>
            <span className="text-sm font-semibold text-zinc-800 w-8 text-center">{data.duration}s</span>
            <button
              onClick={() => onChange({ duration: data.duration + 1 })}
              className="flex h-8 w-8 items-center justify-center rounded-md 

Related in Image & Video