Workbench

The frame of an editor: a canvas with the work on it, a toolbar over it, an inspector beside it and a timeline under it.

'use client';

import {
  CropIcon,
  HandIcon,
  ImageIcon,
  Maximize2Icon,
  Minimize2Icon,
  MousePointer2Icon,
  PauseIcon,
  PlayIcon,
  Redo2Icon,
  ScanSearchIcon,
  TypeIcon,
  Undo2Icon,
  VideoIcon,
  Volume2Icon,
} from 'lucide-react';
import { useState } from 'react';
import { ChoiceCards } from '@/components/panelui/choice-cards';
import { Inspector } from '@/components/panelui/inspector';
import { Slider } from '@/components/panelui/slider';
import { Switch } from '@/components/panelui/switch';
import { Timeline, useTimeline } from '@/components/panelui/timeline';
import { Toolbar } from '@/components/panelui/toolbar';
import { Workbench, useWorkbench } from '@/components/panelui/workbench';
import { XYPad, type XYPadPoint } from '@/components/panelui/xy-pad';

type Span = { id: string; start: number; end: number };

const PERCENT = { style: 'unit', unit: 'percent' } as const;
const BACKDROPS = {
  lagoon: 'linear-gradient(140deg, var(--chart-2), var(--chart-3))',
  dusk: 'linear-gradient(140deg, var(--chart-1), var(--chart-4))',
  plain: 'var(--muted)',
};
const CUTS: Span[] = [
  { id: 'c1', start: 3, end: 19 },
  { id: 'c2', start: 22, end: 37 },
  { id: 'c3', start: 50, end: 63 },
  { id: 'c4', start: 75, end: 94 },
  { id: 'c5', start: 98, end: 118 },
];
const ZOOMS: Span[] = [
  { id: 'z1', start: 12, end: 20 },
  { id: 'z2', start: 56, end: 66.4 },
  { id: 'z3', start: 101, end: 109 },
];

/** Loudness for speech: phrases with breaths between them. Seeded, so every render draws the same. */
function speech(count: number, seed: number) {
  let state = seed;
  const random = () => (state = (state * 16807) % 2147483647) / 2147483647;
  const peaks: number[] = [];
  for (let at = 0, left = 0, phrase = 1, pause = false; at < count; at++, left--) {
    if (left <= 0) {
      pause = !pause && random() < 0.35;
      left = phrase = pause ? 2 + Math.floor(random() * 4) : 10 + Math.floor(random() * 22);
    }
    const swell = Math.sin(Math.PI * (1 - left / phrase)) * 0.55 + 0.3;
    peaks.push(pause ? 0.04 + random() * 0.05 : Math.min(1, swell * (0.55 + random() * 0.6)));
  }
  return peaks;
}
const PEAKS = speech(240, 3);

/** The recording on the artboard: an app window on the backdrop, with the pointer where the lead puts it. */
function Recording({ backdrop, padding, lead }: { backdrop: keyof typeof BACKDROPS; padding: number; lead: XYPadPoint }) {
  return (
    <div className="absolute inset-0" style={{ background: BACKDROPS[backdrop] }}>
      <div
        className="absolute flex flex-col overflow-hidden rounded-[6%/9%] bg-card shadow-2xl ring-1 ring-black/10 transition-[inset] duration-300"
        style={{ inset: `${padding}%` }}
      >
        <div className="flex h-[9%] shrink-0 items-center gap-[1.2%] border-b px-[2.5%]">
          {[0, 1, 2].map((dot) => (
            <span key={dot} className="aspect-square h-[34%] rounded-full bg-muted-foreground/30" />
          ))}
        </div>
        <div className="grid min-h-0 flex-1 grid-cols-[22%_1fr] gap-[3%] p-[3%]">
          <div className="flex flex-col gap-[9%]">
            {[70, 55, 80, 45, 62].map((width, at) => (
              <span key={at} className="h-[6%] rounded-full bg-muted" style={{ width: `${width}%` }} />
            ))}
          </div>
          <div className="grid grid-cols-3 gap-[4%]">
            {['chart-1', 'chart-2', 'chart-5', 'chart-3', 'chart-4', 'chart-2'].map((tone, at) => (
              <span key={at} className="rounded-[10%] bg-muted p-[8%]">
                <span className="block h-1/3 w-2/3 rounded-full opacity-70" style={{ background: `var(--${tone})` }} />
              </span>
            ))}
          </div>
        </div>
      </div>
      {/* The pointer, placed by the inspector's lead pad. */}
      <span
        className="absolute aspect-square w-[4.5%] -translate-x-1/2 -translate-y-1/2 rounded-full bg-white/35 transition-[left,top] duration-300 ring-[3px] ring-white"
        style={{ left: `${50 + lead.x * 40}%`, top: `${50 - lead.y * 40}%` }}
      />
    </div>
  );
}

function Tools() {
  const { focus, setFocus } = useWorkbench();
  return (
    <Toolbar aria-label="Editor">
      <Toolbar.Group defaultValue="select" aria-label="Tools">
        <Toolbar.Tool value="select" label="Select" shortcut="V">
          <MousePointer2Icon />
        </Toolbar.Tool>
        <Toolbar.Tool value="pan" label="Pan" shortcut="H">
          <HandIcon />
        </Toolbar.Tool>
        <Toolbar.Tool value="crop" label="Crop" shortcut="C">
          <CropIcon />
        </Toolbar.Tool>
        <Toolbar.Tool value="zoom" label="Add a zoom" shortcut="Z">
          <ScanSearchIcon />
        </Toolbar.Tool>
        <Toolbar.Tool value="text" label="Text" shortcut="T">
          <TypeIcon />
        </Toolbar.Tool>
      </Toolbar.Group>
      <Toolbar.Separator />
      <Toolbar.Button label={focus ? 'Leave focus' : 'Focus'} shortcut="\" onClick={() => setFocus(!focus)}>
        {focus ? <Minimize2Icon /> : <Maximize2Icon />}
      </Toolbar.Button>
      <Workbench.InspectorTrigger />
    </Toolbar>
  );
}

function Transport() {
  const { playing, setPlaying } = useTimeline();
  return (
    <Toolbar variant="inline" size="sm" aria-label="Playback">
      <Toolbar.Button label={playing ? 'Pause' : 'Play'} onClick={() => setPlaying(!playing)}>
        {playing ? <PauseIcon /> : <PlayIcon />}
      </Toolbar.Button>
      <Toolbar.Separator />
      <Toolbar.Button label="Undo" shortcut="⌘Z">
        <Undo2Icon />
      </Toolbar.Button>
      <Toolbar.Button label="Redo" shortcut="⇧⌘Z" disabled>
        <Redo2Icon />
      </Toolbar.Button>
    </Toolbar>
  );
}

export default function WorkbenchOverview() {
  const [backdrop, setBackdrop] = useState<keyof typeof BACKDROPS>('lagoon');
  const [padding, setPadding] = useState(9);
  const [lead, setLead] = useState<XYPadPoint>({ x: 0.3, y: -0.15 });
  const [cuts, setCuts] = useState(CUTS);
  const [zooms, setZooms] = useState(ZOOMS);
  const [take, setTake] = useState({ start: 0, end: 124 });
  const update = (set: typeof setCuts, id: string) => (span: { start: number; end: number }) =>
    set((all) => all.map((each) => (each.id === id ? { ...each, ...span } : each)));

  return (
    <Workbench className="h-[720px]" defaultTimelineHeight={272}>
      <Workbench.Bar>
        <Tools />
      </Workbench.Bar>

      <Workbench.Canvas>
        <Workbench.Artboard aspect="16 / 10">
          <Recording backdrop={backdrop} padding={padding} lead={lead} />
        </Workbench.Artboard>
      </Workbench.Canvas>

      <Workbench.Inspector>
        <Inspector defaultValue="pointer">
          <Inspector.Rail>
            <Inspector.Tab value="backdrop" label="Backdrop">
              <ImageIcon />
            </Inspector.Tab>
            <Inspector.Tab value="pointer" label="Pointer">
              <MousePointer2Icon />
            </Inspector.Tab>
            <Inspector.Tab value="camera" label="Camera">
              <VideoIcon />
            </Inspector.Tab>
            <Inspector.Tab value="audio" label="Audio">
              <Volume2Icon />
            </Inspector.Tab>
          </Inspector.Rail>
          <Inspector.Panel>
            <Inspector.Page value="pointer">
              <Inspector.Section>
                <Inspector.SectionTitle>Click sound</Inspector.SectionTitle>
                <Inspector.SectionAction>
                  <Switch size="sm" label="Click sound" defaultChecked />
                </Inspector.SectionAction>
                <Inspector.SectionContent>
                  <Slider label="Volume" defaultValue={36} format={PERCENT} />
                </Inspector.SectionContent>
              </Inspector.Section>
              <Inspector.Separator />
              <Inspector.Section>
                <Inspector.SectionTitle>Lead</Inspector.SectionTitle>
                <Inspector.SectionContent>
                  <XYPad label="Lead" value={lead} onValueChange={setLead} aspect="16 / 10" />
                  <Slider label="Smoothing" defaultValue={64} format={PERCENT} />
                  <Slider label="Size" min={0.5} max={3} step={0.05} defaultValue={1.4} format={(v) => `${v.toFixed(2)}×`} />
                </Inspector.SectionContent>
              </Inspector.Section>
            </Inspector.Page>
            <Inspector.Page value="backdrop">
              <Inspector.Section>
                <Inspector.SectionTitle>Backdrop</Inspector.SectionTitle>
                <Inspector.SectionContent>
                  <ChoiceCards
                    value={backdrop}
                    onValueChange={(value) => setBackdrop(value as keyof typeof BACKDROPS)}
                    aspect="1 / 1"
                    aria-label="Backdrop"
                  >
                    {(Object.keys(BACKDROPS) as (keyof typeof BACKDROPS)[]).map((key) => (
                      <ChoiceCards.Item key={key} value={key}>
                        <ChoiceCards.Media>
                          <span className="size-full" style={{ background: BACKDROPS[key] }} />
                        </ChoiceCards.Media>
                        <ChoiceCards.Label className="capitalize">{key}</ChoiceCards.Label>
                      </ChoiceCards.Item>
                    ))}
                  </ChoiceCards>
                  <Slider label="Padding" min={0} max={20} value={padding} onValueChange={setPadding} format={PERCENT} />
                </Inspector.SectionContent>
              </Inspector.Section>
            </Inspector.Page>
            <Inspector.Page value="camera">
              <Inspector.Section>
                <Inspector.SectionTitle>Show camera</Inspector.SectionTitle>
                <Inspector.SectionAction>
                  <Switch size="sm" label="Show camera" />
                </Inspector.SectionAction>
              </Inspector.Section>
            </Inspector.Page>
            <Inspector.Page value="audio">
              <Inspector.Section>
                <Inspector.SectionTitle>Levels</Inspector.SectionTitle>
                <Inspector.SectionContent>
                  <Slider label="Voice" defaultValue={82} format={PERCENT} />
                  <Slider label="Music" defaultValue={20} format={PERCENT} />
                </Inspector.SectionContent>
              </Inspector.Section>
            </Inspector.Page>
          </Inspector.Panel>
        </Inspector>
      </Workbench.Inspector>

      <Workbench.Timeline>
        <Timeline duration={130} defaultZoom={7} defaultTime={54}>
          <Timeline.Header>
            <Transport />
            <Timeline.Time />
            <Timeline.Zoom />
          </Timeline.Header>
          <Timeline.Viewport>
            <Timeline.Ruler />
            <Timeline.Track size="sm" label="Cuts">
              {cuts.map((cut) => (
                <Timeline.Clip key={cut.id} label="Cut" color="warning" start={cut.start} end={cut.end} onChange={update(setCuts, cut.id)} />
              ))}
            </Timeline.Track>
            <Timeline.Track label="Zooms">
              {zooms.map((zoom) => (
                <Timeline.Clip key={zoom.id} label="Zoom" color="chart-2" start={zoom.start} end={zoom.end} onChange={update(setZooms, zoom.id)} />
              ))}
            </Timeline.Track>
            <Timeline.Track size="lg" label="Recording">
              <Timeline.Clip label="Onboarding walkthrough" color="info" start={take.start} end={take.end} onChange={setTake}>
                <Timeline.Waveform peaks={PEAKS} />
                <Timeline.ClipLabel>
                  <VideoIcon />
                  Onboarding walkthrough
                </Timeline.ClipLabel>
              </Timeline.Clip>
            </Timeline.Track>
            <Timeline.Playhead />
          </Timeline.Viewport>
        </Timeline>
      </Workbench.Timeline>
    </Workbench>
  );
}

Use it as the frame of an editor in the browser: a screen-recording editor, a poster designer, an animation tool. It lays out the canvas with the work on it, a toolbar floating over the canvas, an Inspector beside it, a sidebar before it and a Timeline under it. Use only the parts the editor needs; a column that is not there takes no room.

It fills the box it is given, so give it a height: h-dvh for a whole page, a fixed height in a layout.

Below 760 pixels wide, compactBelow by default, the inspector leaves its column and opens as a Sheet from the side; put a Workbench.InspectorTrigger in the bar to open it. Focus slides every panel away and gives the canvas the room, and \ toggles it.

Installation

npx shadcn@latest add https://panelui.dev/r/web/workbench.json

The source is copied into components/panelui/, where you can edit it.

npm install panelui-web

Import from panelui-web/workbench instead of @/components/panelui/workbench. Tailwind needs one line to read the package's classes, which is on the installation page.

Install the packages it uses:

npm install tailwind-variants

Add the component it is built on: Sheet.

Copy the source into components/panelui/workbench.tsx:

Usage

import { Workbench } from "@/components/panelui/workbench"
<Workbench className="h-dvh">
  <Workbench.Bar>
    <Toolbar>…</Toolbar>
  </Workbench.Bar>
  <Workbench.Canvas>
    <Workbench.Artboard aspect="16 / 10">…</Workbench.Artboard>
  </Workbench.Canvas>
  <Workbench.Inspector>
    <Inspector>…</Inspector>
  </Workbench.Inspector>
  <Workbench.Timeline>
    <Timeline duration={130}>…</Timeline>
  </Workbench.Timeline>
</Workbench>

Composition

<Workbench>
  <Workbench.Bar>
    <Toolbar>
      <Workbench.InspectorTrigger />
    </Toolbar>
  </Workbench.Bar>
  <Workbench.Sidebar />
  <Workbench.Canvas>
    <Workbench.Artboard />
  </Workbench.Canvas>
  <Workbench.Inspector>
    <Inspector />
  </Workbench.Inspector>
  <Workbench.Timeline>
    <Timeline />
  </Workbench.Timeline>
</Workbench>
  • Workbench.Bar — The strip floating at the top of the canvas, for a Toolbar. The canvas keeps the room under it clear, so the work is never behind it.
  • Workbench.Sidebar — A column before the canvas, for layers, pages or assets. It is left out on a narrow screen and slides away in focus.
  • Workbench.Canvas — The stage: a field of dots with the work centred on it.
  • Workbench.Artboard — The work, at the largest size its aspect fits on the canvas, with a shadow to stand off the dots.
  • Workbench.Inspector — The column after the canvas, for an Inspector. On a narrow screen the same children open in a sheet from the side, headed by title.
  • Workbench.InspectorTrigger — The button that opens the inspector's sheet. It only renders while the inspector is a sheet, so it can sit in the bar on every size.
  • Workbench.Timeline — The row under everything, for a Timeline. Dragging its top edge, or the arrow keys on it, makes it taller or shorter.

Examples

A poster

A sidebar of layers before the canvas, whose eye buttons hide and show each part of the poster, and an inspector with no rail for colour and type. With no timeline, the canvas runs to the bottom.

'use client';

import { AlignCenterIcon, AlignLeftIcon, AlignRightIcon, EyeIcon, EyeOffIcon, ImageIcon, MousePointer2Icon, SquareIcon, TypeIcon } from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/panelui/button';
import { ChoiceCards } from '@/components/panelui/choice-cards';
import { Frame } from '@/components/panelui/frame';
import { Inspector } from '@/components/panelui/inspector';
import { Slider } from '@/components/panelui/slider';
import { Toolbar } from '@/components/panelui/toolbar';
import { Workbench } from '@/components/panelui/workbench';

const PALETTES = {
  dusk: ['var(--chart-4)', 'var(--chart-1)', 'var(--chart-5)'],
  grove: ['var(--chart-2)', 'var(--chart-3)', 'var(--chart-4)'],
  ink: ['var(--foreground)', 'var(--muted-foreground)', 'var(--chart-5)'],
};
type Palette = keyof typeof PALETTES;
type Align = 'left' | 'center' | 'right';

const LAYERS = [
  { id: 'title', name: 'Title', icon: TypeIcon },
  { id: 'dates', name: 'Dates', icon: TypeIcon },
  { id: 'sun', name: 'Sun', icon: SquareIcon },
  { id: 'hills', name: 'Hills', icon: ImageIcon },
];

export default function WorkbenchPoster() {
  const [hidden, setHidden] = useState<string[]>([]);
  const [palette, setPalette] = useState<Palette>('dusk');
  const [size, setSize] = useState(64);
  const [tracking, setTracking] = useState(-2);
  const [align, setAlign] = useState<Align>('left');
  const [deep, mid, light] = PALETTES[palette];
  const shown = (id: string) => !hidden.includes(id);

  return (
    <Workbench className="h-[640px]" inspectorWidth={300}>
      <Workbench.Bar>
        <Toolbar aria-label="Tools">
          <Toolbar.Group defaultValue="select" aria-label="Tools">
            <Toolbar.Tool value="select" label="Select" shortcut="V">
              <MousePointer2Icon />
            </Toolbar.Tool>
            <Toolbar.Tool value="text" label="Text" shortcut="T">
              <TypeIcon />
            </Toolbar.Tool>
            <Toolbar.Tool value="shape" label="Shape" shortcut="R">
              <SquareIcon />
            </Toolbar.Tool>
          </Toolbar.Group>
          <Workbench.InspectorTrigger />
        </Toolbar>
      </Workbench.Bar>

      <Workbench.Sidebar>
        <Frame className="h-full">
          <Frame.Header>
            <Frame.Title>Layers</Frame.Title>
          </Frame.Header>
          <Frame.Panel>
            {LAYERS.map(({ id, name, icon: Icon }) => (
              <Frame.Row key={id}>
                <Frame.Media className="text-muted-foreground">
                  <Icon className="size-4" />
                </Frame.Media>
                <Frame.Content>
                  <Frame.Title as="p">{name}</Frame.Title>
                </Frame.Content>
                <Frame.Actions>
                  <Button
                    size="icon-sm"
                    variant="ghost"
                    aria-label={shown(id) ? `Hide ${name}` : `Show ${name}`}
                    onClick={() => setHidden((all) => (all.includes(id) ? all.filter((each) => each !== id) : [...all, id]))}
                  >
                    {shown(id) ? <EyeIcon /> : <EyeOffIcon />}
                  </Button>
                </Frame.Actions>
              </Frame.Row>
            ))}
          </Frame.Panel>
        </Frame>
      </Workbench.Sidebar>

      <Workbench.Canvas>
        <Workbench.Artboard aspect="4 / 5" className="[container-type:inline-size]">
          {/* The poster, drawn in units of its own width. */}
          <div className="absolute inset-0" style={{ background: `linear-gradient(180deg, ${light} -20%, ${deep} 95%)` }}>
            {shown('sun') ? (
              <span
                className="absolute top-[14%] left-[56%] size-[30cqw] rounded-full"
                style={{ background: `radial-gradient(circle at 40% 35%, white, ${light} 55%, ${mid})` }}
              />
            ) : null}
            {shown('hills') ? (
              <svg viewBox="0 0 100 40" preserveAspectRatio="none" className="absolute inset-x-0 bottom-0 h-[42%] w-full">
                <path d="M0 22 C 18 8, 32 30, 50 18 S 82 6, 100 20 V40 H0Z" fill={mid} opacity={0.75} />
                <path d="M0 30 C 22 20, 40 36, 60 26 S 88 22, 100 30 V40 H0Z" fill={deep} />
              </svg>
            ) : null}
            <div className="absolute inset-x-[8%] bottom-[9%] flex flex-col gap-[2cqw] text-white" style={{ textAlign: align }}>
              {shown('title') ? (
                <p className="font-semibold leading-[0.9]" style={{ fontSize: `${size / 4.2}cqw`, letterSpacing: `${tracking / 100}em` }}>
                  Night
                  <br />
                  Market
                </p>
              ) : null}
              {shown('dates') ? <p className="text-[3.6cqw] font-medium opacity-85">Fridays in June · Harbour Steps</p> : null}
            </div>
          </div>
        </Workbench.Artboard>
      </Workbench.Canvas>

      <Workbench.Inspector title="Poster">
        <Inspector>
          <Inspector.Panel>
            <Inspector.Section>
              <Inspector.SectionTitle>Colour</Inspector.SectionTitle>
              <Inspector.SectionContent>
                <ChoiceCards value={palette} onValueChange={(next) => setPalette(next as Palette)} aspect="3 / 2" aria-label="Palette">
                  {(Object.keys(PALETTES) as Palette[]).map((key) => (
                    <ChoiceCards.Item key={key} value={key}>
                      <ChoiceCards.Media>
                        <span className="flex size-full">
                          {PALETTES[key].map((tone) => (
                            <span key={tone} className="flex-1" style={{ background: tone }} />
                          ))}
                        </span>
                      </ChoiceCards.Media>
                      <ChoiceCards.Label className="capitalize">{key}</ChoiceCards.Label>
                    </ChoiceCards.Item>
                  ))}
                </ChoiceCards>
              </Inspector.SectionContent>
            </Inspector.Section>
            <Inspector.Separator />
            <Inspector.Section>
              <Inspector.SectionTitle>Title</Inspector.SectionTitle>
              <Inspector.SectionAction>
                <Toolbar variant="inline" size="sm" aria-label="Alignment">
                  <Toolbar.Group value={align} onValueChange={(next) => setAlign(next as Align)} aria-label="Alignment">
                    <Toolbar.Tool value="left" label="Align left">
                      <AlignLeftIcon />
                    </Toolbar.Tool>
                    <Toolbar.Tool value="center" label="Centre">
                      <AlignCenterIcon />
                    </Toolbar.Tool>
                    <Toolbar.Tool value="right" label="Align right">
                      <AlignRightIcon />
                    </Toolbar.Tool>
                  </Toolbar.Group>
                </Toolbar>
              </Inspector.SectionAction>
              <Inspector.SectionContent>
                <Slider label="Size" min={32} max={96} value={size} onValueChange={setSize} format={(v) => `${v} pt`} />
                <Slider
                  label="Tracking"
                  origin="center"
                  min={-8}
                  max={8}
                  step={0.5}
                  value={tracking}
                  onValueChange={setTracking}
                  format={(v) => `${v > 0 ? '+' : ''}${v}%`}
                />
              </Inspector.SectionContent>
            </Inspector.Section>
          </Inspector.Panel>
        </Inspector>
      </Workbench.Inspector>
    </Workbench>
  );
}

Motion

The canvas draws the work at the playhead's moment. Here each tween on the timeline moves a property of the card, so dragging a clip or the playhead changes what the canvas shows, and play runs the entrance.

'use client';

import { PauseIcon, PlayIcon, RepeatIcon } from 'lucide-react';
import { useState } from 'react';
import { Timeline, useTimeline, type TimelineColor } from '@/components/panelui/timeline';
import { Toolbar } from '@/components/panelui/toolbar';
import { Workbench } from '@/components/panelui/workbench';

type Tween = { name: string; color: TimelineColor; start: number; end: number; from: number; to: number };

const TWEENS: Tween[] = [
  { name: 'Opacity', color: 'chart-2', start: 0, end: 0.35, from: 0, to: 1 },
  { name: 'Lift', color: 'info', start: 0.05, end: 0.75, from: 48, to: 0 },
  { name: 'Scale', color: 'destructive', start: 0.15, end: 0.95, from: 0.86, to: 1 },
  { name: 'Blur', color: 'warning', start: 0, end: 0.5, from: 10, to: 0 },
];

/** Where a tween is at a time: eased out, so it arrives softly. */
const at = (tween: Tween, time: number) => {
  const progress = Math.min(Math.max((time - tween.start) / (tween.end - tween.start), 0), 1);
  const eased = 1 - Math.pow(1 - progress, 3);
  return tween.from + (tween.to - tween.from) * eased;
};

function Transport() {
  const { playing, setPlaying, time, duration, setTime } = useTimeline();
  return (
    <Toolbar variant="inline" size="sm" aria-label="Playback">
      <Toolbar.Button
        label={playing ? 'Pause' : 'Play'}
        onClick={() => {
          if (!playing && time >= duration) setTime(0);
          setPlaying(!playing);
        }}
      >
        {playing ? <PauseIcon /> : <PlayIcon />}
      </Toolbar.Button>
      <Toolbar.Button label="Replay" onClick={() => (setTime(0), setPlaying(true))}>
        <RepeatIcon />
      </Toolbar.Button>
    </Toolbar>
  );
}

export default function WorkbenchMotion() {
  const [tweens, setTweens] = useState(TWEENS);
  const [time, setTime] = useState(0.45);
  const value = (name: string) => at(tweens.find((tween) => tween.name === name)!, time);

  return (
    <Workbench className="h-[600px]" defaultTimelineHeight={232}>
      <Workbench.Canvas>
        <Workbench.Artboard aspect="16 / 10" className="[container-type:inline-size]">
          <div className="absolute inset-0 flex items-center justify-center bg-muted">
            {/* The card the tweens animate, drawn at the playhead's moment. */}
            <div
              className="flex w-[44cqw] flex-col gap-[2.2cqw] rounded-[3cqw] bg-card p-[2.6cqw] shadow-xl ring-1 ring-foreground/10"
              style={{
                opacity: value('Opacity'),
                transform: `translateY(${value('Lift')}%) scale(${value('Scale')})`,
                filter: `blur(${value('Blur')}px)`,
              }}
            >
              <span className="aspect-[16/7] w-full rounded-[1.8cqw]" style={{ background: 'linear-gradient(120deg, var(--chart-2), var(--chart-4))' }} />
              <span className="h-[1.8cqw] w-3/4 rounded-full bg-foreground/70" />
              <span className="h-[1.3cqw] w-1/2 rounded-full bg-muted-foreground/40" />
              <span className="mt-[1cqw] h-[4.4cqw] w-[34%] rounded-full bg-primary" />
            </div>
          </div>
        </Workbench.Artboard>
      </Workbench.Canvas>

      <Workbench.Timeline>
        <Timeline duration={1.2} time={time} onTimeChange={setTime} defaultZoom={760} minZoom={300} maxZoom={1400}>
          <Timeline.Header>
            <Transport />
            <Timeline.Time />
            <Timeline.Zoom />
          </Timeline.Header>
          <Timeline.Viewport>
            <Timeline.Ruler />
            {tweens.map((tween, index) => (
              <Timeline.Track key={tween.name} size="sm" label={tween.name}>
                <Timeline.Clip
                  variant="bar"
                  label={tween.name}
                  color={tween.color}
                  start={tween.start}
                  end={tween.end}
                  onChange={(span) => setTweens((all) => all.map((each, i) => (i === index ? { ...each, ...span } : each)))}
                >
                  <Timeline.ClipLabel className="text-[11px]">{tween.name}</Timeline.ClipLabel>
                </Timeline.Clip>
              </Timeline.Track>
            ))}
            <Timeline.Playhead />
          </Timeline.Viewport>
        </Timeline>
      </Workbench.Timeline>
    </Workbench>
  );
}

A narrow screen

Under compactBelow pixels wide the inspector is a sheet. The Workbench.InspectorTrigger in the bar appears with it and opens it; on a wider screen the trigger is not there and the inspector stands in its column.

'use client';

import { CropIcon, MousePointer2Icon, StickerIcon, TypeIcon } from 'lucide-react';
import { useState } from 'react';
import { Inspector } from '@/components/panelui/inspector';
import { Slider } from '@/components/panelui/slider';
import { Switch } from '@/components/panelui/switch';
import { Toolbar } from '@/components/panelui/toolbar';
import { Workbench } from '@/components/panelui/workbench';

const PERCENT = { style: 'unit', unit: 'percent' } as const;

export default function WorkbenchNarrow() {
  const [warmth, setWarmth] = useState(24);
  const [vignette, setVignette] = useState(true);
  return (
    <div className="w-full max-w-[380px]">
      <Workbench className="h-[600px]">
        <Workbench.Bar>
          <Toolbar size="sm" aria-label="Tools">
            <Toolbar.Group defaultValue="select" aria-label="Tools">
              <Toolbar.Tool value="select" label="Select">
                <MousePointer2Icon />
              </Toolbar.Tool>
              <Toolbar.Tool value="crop" label="Crop">
                <CropIcon />
              </Toolbar.Tool>
              <Toolbar.Tool value="text" label="Text">
                <TypeIcon />
              </Toolbar.Tool>
              <Toolbar.Tool value="sticker" label="Sticker">
                <StickerIcon />
              </Toolbar.Tool>
            </Toolbar.Group>
            <Toolbar.Separator />
            {/* Only there while the inspector is a sheet. */}
            <Workbench.InspectorTrigger className="size-7 rounded-lg" />
          </Toolbar>
        </Workbench.Bar>
        <Workbench.Canvas>
          <Workbench.Artboard aspect="9 / 16">
            <div
              className="absolute inset-0"
              style={{
                background: `linear-gradient(170deg, color-mix(in oklab, var(--chart-3) ${warmth}%, var(--chart-5)), var(--chart-4))`,
              }}
            >
              {vignette ? <span className="absolute inset-0 bg-[radial-gradient(circle,transparent_45%,rgb(0_0_0/0.45))]" /> : null}
              <span className="absolute inset-x-[10%] bottom-[12%] h-[5%] rounded-full bg-white/85" />
              <span className="absolute inset-x-[10%] right-[35%] bottom-[20%] h-[3%] rounded-full bg-white/60" />
            </div>
          </Workbench.Artboard>
        </Workbench.Canvas>
        <Workbench.Inspector title="Adjust">
          <Inspector>
            <Inspector.Panel>
              <Inspector.Section>
                <Inspector.SectionTitle>Light</Inspector.SectionTitle>
                <Inspector.SectionContent>
                  <Slider label="Warmth" value={warmth} onValueChange={setWarmth} format={PERCENT} color="warning" />
                  <Slider label="Grain" defaultValue={12} format={PERCENT} />
                </Inspector.SectionContent>
              </Inspector.Section>
              <Inspector.Separator />
              <Inspector.Section>
                <Inspector.SectionTitle>Vignette</Inspector.SectionTitle>
                <Inspector.SectionAction>
                  <Switch size="sm" label="Vignette" checked={vignette} onCheckedChange={setVignette} />
                </Inspector.SectionAction>
              </Inspector.Section>
            </Inspector.Panel>
          </Inspector>
        </Workbench.Inspector>
      </Workbench>
    </div>
  );
}

API Reference

Workbench

PropTypeDefaultDescription
focusboolean—Whether the panels are cleared away, when the caller holds it.
defaultFocusbooleanfalseWhether it starts in focus. Default false.
onFocusChange(focus: boolean) => void—Called when focus turns on or off.
focusShortcutbooleantrueToggle focus with the \ key while nothing is being typed. Default true.
compactBelownumber760Below this many pixels wide, the inspector opens as a sheet. Default 760.
inspectorWidthnumber348The inspector column's width in pixels. Default 348, a rail and a panel.
sidebarWidthnumber240The sidebar's width in pixels. Default 240.
defaultTimelineHeightnumber232The timeline's height in pixels to start at. Default 232.

Workbench.Artboard

PropTypeDefaultDescription
aspectstring—The work's shape, as a CSS aspect ratio such as 16 / 9 or 4 / 5. Default 16 / 9.

Workbench.Inspector

PropTypeDefaultDescription
titlestring—The sheet's heading on a narrow screen, read out when it opens. Default Inspector.

Workbench.Timeline

PropTypeDefaultDescription
resizableboolean—Let its top edge be dragged to make it taller or shorter. Default true.
minHeightnumber—The shortest it can be dragged to, in pixels. Default 140.

Every part also takes the props of the element it renders, and a className.

Notes

Focus

\ toggles focus while nothing is being typed; focusShortcut={false} turns that off. focus, defaultFocus and onFocusChange hold it, and useWorkbench() gives a toolbar button the same switch, as in the first example. Panels that have slid away cannot be reached with the keyboard.

Sizes

inspectorWidth and sidebarWidth set the columns, 348 and 240 pixels by default; 348 fits an Inspector with its rail. defaultTimelineHeight sets where the timeline starts, and it can be dragged from 140 pixels up to 60% of the workbench.

Narrow screens

The width that decides is the workbench's own, not the window's, so a workbench in a pane changes with the pane.

With reduced motion turned on, panels appear and disappear without sliding.

Server Components

Workbench is a client component. In a file that starts with "use client", write its parts with a dot, as on this page. A Server Component cannot read Workbench.Bar off the import, so each part is also exported by name: WorkbenchBar is Workbench.Bar, and so on for the others.

import { Workbench, WorkbenchBar, WorkbenchSidebar, WorkbenchCanvas, WorkbenchArtboard, WorkbenchInspector, WorkbenchInspectorTrigger, WorkbenchTimeline } from "@/components/panelui/workbench"