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.jsonpnpm dlx shadcn@latest add https://panelui.dev/r/web/workbench.jsonyarn dlx shadcn@latest add https://panelui.dev/r/web/workbench.jsonbunx shadcn@latest add https://panelui.dev/r/web/workbench.jsonThe source is copied into components/panelui/, where you can edit it.
npm install panelui-webpnpm add panelui-webyarn add panelui-webbun add panelui-webImport 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-variantspnpm add tailwind-variantsyarn add tailwind-variantsbun add tailwind-variantsAdd 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 itsaspectfits 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 bytitle.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
| Prop | Type | Default | Description |
|---|---|---|---|
focus | boolean | — | Whether the panels are cleared away, when the caller holds it. |
defaultFocus | boolean | false | Whether it starts in focus. Default false. |
onFocusChange | (focus: boolean) => void | — | Called when focus turns on or off. |
focusShortcut | boolean | true | Toggle focus with the \ key while nothing is being typed. Default true. |
compactBelow | number | 760 | Below this many pixels wide, the inspector opens as a sheet. Default 760. |
inspectorWidth | number | 348 | The inspector column's width in pixels. Default 348, a rail and a panel. |
sidebarWidth | number | 240 | The sidebar's width in pixels. Default 240. |
defaultTimelineHeight | number | 232 | The timeline's height in pixels to start at. Default 232. |
Workbench.Artboard
| Prop | Type | Default | Description |
|---|---|---|---|
aspect | string | — | The work's shape, as a CSS aspect ratio such as 16 / 9 or 4 / 5. Default 16 / 9. |
Workbench.Inspector
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | — | The sheet's heading on a narrow screen, read out when it opens. Default Inspector. |
Workbench.Timeline
| Prop | Type | Default | Description |
|---|---|---|---|
resizable | boolean | — | Let its top edge be dragged to make it taller or shorter. Default true. |
minHeight | number | — | 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"