Page assembly
Components lift out of a palette, fly to their place and land, until the page is complete and published. On its own or with the scroll.
One of 4 components in Animations
Details
What Page assembly brings along, read from the registry and the source.
- Category
- Animations
- Area
- Visuals
- Variants
- 3
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/page-assembly.tsx
- Brings along
- utilskitmotionmotion-scene
- npm packages
- None
- Languages
- English and German, 13 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The PageAssemblyProps interface from components/ui/page-assembly.tsx, with the comments from the code. Also takes every prop of SceneMotionProps.
presetoptional'landing' | 'app'A ready set: a landing page or an app screen
blocksoptionalAssemblyBlock[]Your own blocks instead of the preset
modeoptional'time' | 'scroll'time: plays on its own (default). scroll: each block lands as you scroll.
urloptionalstringAddress in the browser bar
labeloptionalstring
Built-in texts
Page assembly writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 13 texts
| Key | English | German |
|---|---|---|
| palette | English Components | German Components |
| published | English Published | German Veröffentlicht |
| nav | English Navbar | German Navbar |
| hero | English Hero | German Hero |
| features | English Features | German Features |
| pricing | English Pricing | German Pricing |
| footer | English Footer | German Footer |
| sidebar | English Sidebar | German Sidebar |
| header | English Header | German Header |
| kpis | English Stats | German Stats |
| chart | English Chart | German Chart |
| activity | English Activity | German Aktivität |
| table | English Table | German Tabelle |
Accessibility
Read from the source of the component.
- Sets aria-label, so screen readers announce purpose and state.
- Uses the role label.
- Needs no client state. The server writes it in full, so it reads without JavaScript too.
Motion
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 405 lines
'use client';
import { cn, round, useTexts } from '@/lib/kit';
import { useScrollProgress } from '@/components/ui/motion';
import { FitCanvas, PlayToggle, SCENE_CSS, SceneTimeline, Track, useSceneId, useScenePlay, type SceneMotionProps } from '@/components/ui/motion-scene';
/*
* Components put themselves together into a page. A palette of components
* on the left, an empty browser on the right. One after another a component
* lifts, flies to its place and lands, the section appears where it lands,
* and at the end the page is published. Either on its own timeline or tied
* to the scroll. Drawn at a design size of 760 × 500 and scaled as one
* picture. The finished page is the still picture.
*/
export interface AssemblySlot {
x: number;
y: number;
w: number;
h: number;
}
export interface AssemblyBlock {
id: string;
/** Name on the chip in the palette */
label: string;
/** Small mark on the chip */
icon?: React.ReactNode;
/** Where it lands, in design px of the 760 × 500 canvas */
slot: AssemblySlot;
/** What appears in the slot */
content: React.ReactNode;
}
const TEXTS = {
en: { palette: 'Components', published: 'Published', nav: 'Navbar', hero: 'Hero', features: 'Features', pricing: 'Pricing', footer: 'Footer', sidebar: 'Sidebar', header: 'Header', kpis: 'Stats', chart: 'Chart', activity: 'Activity', table: 'Table' },
de: { palette: 'Components', published: 'Veröffentlicht', nav: 'Navbar', hero: 'Hero', features: 'Features', pricing: 'Pricing', footer: 'Footer', sidebar: 'Sidebar', header: 'Header', kpis: 'Stats', chart: 'Chart', activity: 'Aktivität', table: 'Tabelle' },
};
export interface PageAssemblyProps extends SceneMotionProps {
/** A ready set: a landing page or an app screen */
preset?: 'landing' | 'app';
/** Your own blocks instead of the preset */
blocks?: AssemblyBlock[];
/** time: plays on its own (default). scroll: each block lands as you scroll. */
mode?: 'time' | 'scroll';
/** Address in the browser bar */
url?: string;
label?: string;
labels?: Partial<typeof TEXTS.en>;
className?: string;
}
const CW = 760;
const CH = 500;
const BROWSER = { x: 214, y: 20, w: 526, h: 460 };
/* ---------- drawn sections ---------- */
const bar = (w: string, tone = 'bg-muted', h = 'h-2') => <span className={cn('block rounded-full', h, tone)} style={{ width: w }} />;
function NavSketch() {
return (
<div className="flex h-full items-center justify-between rounded-md border border-border bg-card px-3">
<span className="flex items-center gap-1.5"><span className="size-4 rounded-[5px] bg-primary" />{bar('44px', 'bg-foreground/70')}</span>
<span className="flex gap-3">{bar('26px')}{bar('30px')}{bar('24px')}</span>
<span className="h-5 w-14 rounded-md bg-primary" />
</div>
);
}
function HeroSketch() {
return (
<div className="grid h-full grid-cols-[1fr_0.9fr] gap-3 rounded-md border border-border bg-card p-3">
<div className="flex flex-col justify-center gap-2">
{bar('40%', 'bg-[color-mix(in_oklab,var(--primary)_60%,transparent)]', 'h-1.5')}
{bar('92%', 'bg-foreground/80', 'h-3.5')}
{bar('70%', 'bg-foreground/80', 'h-3.5')}
{bar('85%', 'bg-muted', 'h-1.5')}
<span className="mt-1 flex gap-1.5"><span className="h-5 w-14 rounded-md bg-primary" /><span className="h-5 w-12 rounded-md border border-border-strong" /></span>
</div>
<div className="relative overflow-hidden rounded-md bg-[linear-gradient(150deg,color-mix(in_oklab,var(--primary)_40%,var(--surface-2)),color-mix(in_oklab,var(--primary)_8%,var(--surface)))]">
<span className="absolute bottom-0 left-0 right-0 h-1/2 bg-[color-mix(in_oklab,var(--foreground)_10%,transparent)] [clip-path:polygon(0_60%,30%_20%,55%_55%,80%_10%,100%_40%,100%_100%,0_100%)]" />
<span className="absolute right-4 top-3 size-5 rounded-full bg-[color-mix(in_oklab,var(--primary)_55%,#fff)]" />
</div>
</div>
);
}
function CardsSketch({ n = 3, price = false }: { n?: number; price?: boolean }) {
return (
<div className="grid h-full gap-2" style={{ gridTemplateColumns: `repeat(${n}, minmax(0, 1fr))` }}>
{Array.from({ length: n }, (_, i) => (
<div key={i} className={cn('flex flex-col gap-1.5 rounded-md border bg-card p-2.5', price && i === 1 ? 'border-[color-mix(in_oklab,var(--primary)_55%,var(--border))]' : 'border-border')}>
{price ? (
<>
{bar('40%', 'bg-muted', 'h-1.5')}
{bar('55%', 'bg-foreground/80', 'h-3')}
<span className="mt-auto h-4 w-full rounded-[5px]" style={{ background: i === 1 ? 'var(--primary)' : 'var(--muted)' }} />
</>
) : (
<>
<span className="size-4 rounded-[5px] bg-[color-mix(in_oklab,var(--primary)_22%,transparent)]" />
{bar('70%', 'bg-foreground/70', 'h-1.5')}
{bar('90%', 'bg-muted', 'h-1.5')}
{bar('60%', 'bg-muted', 'h-1.5')}
</>
)}
</div>
))}
</div>
);
}
function FooterSketch() {
return (
<div className="flex h-full items-center justify-between rounded-md border border-border bg-surface px-3">
<span className="flex items-center gap-1.5"><span className="size-3 rounded-[4px] bg-foreground/60" />{bar('36px')}</span>
<span className="flex gap-2.5">{bar('22px')}{bar('26px')}{bar('20px')}</span>
</div>
);
}
function SidebarSketch() {
return (
<div className="flex h-full flex-col gap-2 rounded-md border border-border bg-surface p-2.5">
<span className="mb-1 flex items-center gap-1.5"><span className="size-4 rounded-[5px] bg-primary" />{bar('40px', 'bg-foreground/70')}</span>
{[70, 55, 62, 48, 58].map((w, i) => (
<span key={i} className={cn('flex items-center gap-1.5 rounded-[5px] px-1.5 py-1', i === 0 && 'bg-accent')}>
<span className="size-2.5 rounded-[3px] bg-foreground/40" />
{bar(`${w}%`, i === 0 ? 'bg-foreground/70' : 'bg-muted', 'h-1.5')}
</span>
))}
</div>
);
}
function HeaderSketch() {
return (
<div className="flex h-full items-center justify-between rounded-md border border-border bg-card px-3">
{bar('80px', 'bg-foreground/80', 'h-2.5')}
<span className="flex items-center gap-2"><span className="h-5 w-24 rounded-md border border-border" /><span className="size-5 rounded-full bg-[color-mix(in_oklab,var(--primary)_40%,var(--muted))]" /></span>
</div>
);
}
function KpiSketch() {
return (
<div className="grid h-full grid-cols-4 gap-2">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="flex flex-col justify-center gap-1.5 rounded-md border border-border bg-card px-2.5">
{bar('50%', 'bg-muted', 'h-1.5')}
{bar('70%', 'bg-foreground/80', 'h-3')}
{bar('30%', 'bg-[color-mix(in_oklab,var(--success)_60%,transparent)]', 'h-1.5')}
</div>
))}
</div>
);
}
function ChartSketch() {
return (
<div className="flex h-full flex-col rounded-md border border-border bg-card p-2.5">
{bar('34%', 'bg-foreground/70', 'h-1.5')}
<svg viewBox="0 0 200 90" preserveAspectRatio="none" className="mt-2 w-full flex-1">
<path d="M0 76C30 70 40 58 70 56S110 40 130 34S170 18 200 10V90H0Z" fill="color-mix(in oklab, var(--primary) 18%, transparent)" />
<path d="M0 76C30 70 40 58 70 56S110 40 130 34S170 18 200 10" fill="none" stroke="var(--primary)" strokeWidth="2" vectorEffect="non-scaling-stroke" />
</svg>
</div>
);
}
function ActivitySketch() {
return (
<div className="flex h-full flex-col gap-2 rounded-md border border-border bg-card p-2.5">
{bar('46%', 'bg-foreground/70', 'h-1.5')}
{[0, 1, 2, 3].map((i) => (
<span key={i} className="flex items-center gap-1.5">
<span className="size-3.5 rounded-full" style={{ background: ['#c9a27e', '#7d97b5', '#9cae84', '#b48aa8'][i] }} />
{bar(`${[70, 55, 64, 48][i]}%`, 'bg-muted', 'h-1.5')}
</span>
))}
</div>
);
}
function TableSketch() {
return (
<div className="flex h-full flex-col rounded-md border border-border bg-card">
{[0, 1, 2, 3].map((i) => (
<span key={i} className={cn('flex flex-1 items-center gap-3 px-3', i > 0 && 'border-t border-border')}>
{bar('14%', i === 0 ? 'bg-muted-foreground/50' : 'bg-foreground/60', 'h-1.5')}
{bar('30%', 'bg-muted', 'h-1.5')}
<span className="ml-auto">{i > 0 ? <span className="block h-3 w-10 rounded-[4px] bg-[color-mix(in_oklab,var(--primary)_16%,transparent)]" /> : bar('34px', 'bg-muted-foreground/50', 'h-1.5')}</span>
</span>
))}
</div>
);
}
const glyph = (d: string) => (
<svg viewBox="0 0 16 16" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d={d} />
</svg>
);
function presetBlocks(preset: 'landing' | 'app', t: typeof TEXTS.en): AssemblyBlock[] {
const X = 224;
const W = 506;
if (preset === 'app')
return [
{ id: 'sidebar', label: t.sidebar, icon: glyph('M2.5 3h11v10h-11ZM6 3v10'), slot: { x: X, y: 62, w: 104, h: 408 }, content: <SidebarSketch /> },
{ id: 'header', label: t.header, icon: glyph('M2.5 3.5h11M2.5 7h7'), slot: { x: 336, y: 62, w: 394, h: 36 }, content: <HeaderSketch /> },
{ id: 'kpis', label: t.kpis, icon: glyph('M3 12V8M6.5 12V4M10 12V6.5M13.5 12V9'), slot: { x: 336, y: 106, w: 394, h: 66 }, content: <KpiSketch /> },
{ id: 'chart', label: t.chart, icon: glyph('M2.5 12 6 8l3 2.5 4.5-6'), slot: { x: 336, y: 180, w: 236, h: 150 }, content: <ChartSketch /> },
{ id: 'activity', label: t.activity, icon: glyph('M2.5 8h2.5l2-4 2.5 8 2-4h2'), slot: { x: 580, y: 180, w: 150, h: 150 }, content: <ActivitySketch /> },
{ id: 'table', label: t.table, icon: glyph('M2.5 3h11v10h-11ZM2.5 6.5h11M2.5 10h11'), slot: { x: 336, y: 338, w: 394, h: 132 }, content: <TableSketch /> },
];
return [
{ id: 'nav', label: t.nav, icon: glyph('M2.5 4h11M2.5 8h11M2.5 12h11'), slot: { x: X, y: 62, w: W, h: 34 }, content: <NavSketch /> },
{ id: 'hero', label: t.hero, icon: glyph('M2.5 3h11v10h-11ZM5 9.5l2.5-2.5L10 9.5l1.5-1.5'), slot: { x: X, y: 104, w: W, h: 132 }, content: <HeroSketch /> },
{ id: 'features', label: t.features, icon: glyph('M2.5 3h4.5v4.5H2.5ZM9 3h4.5v4.5H9ZM2.5 9h4.5v4.5H2.5ZM9 9h4.5v4.5H9Z'), slot: { x: X, y: 244, w: W, h: 96 }, content: <CardsSketch /> },
{ id: 'pricing', label: t.pricing, icon: glyph('M8 2.5v11M10.5 5H7a1.75 1.75 0 0 0 0 3.5h2a1.75 1.75 0 0 1 0 3.5H5.5'), slot: { x: X, y: 348, w: W, h: 78 }, content: <CardsSketch price /> },
{ id: 'footer', label: t.footer, icon: glyph('M2.5 12h11M2.5 9h6'), slot: { x: X, y: 434, w: W, h: 36 }, content: <FooterSketch /> },
];
}
/**
* <PageAssembly preset="landing" url="atlas-outdoor.com" />
* <PageAssembly preset="app" mode="scroll" />
*/
export function PageAssembly({ preset = 'landing', blocks, mode = 'time', url = 'your-site.com', speed = 1, autoPlay = 'in-view', loop = true, controls = true, label, labels, className }: PageAssemblyProps) {
const sid = useSceneId('kvpa');
const play = useScenePlay<HTMLDivElement>(autoPlay);
const progress = useScrollProgress<HTMLDivElement>({ start: 0.15, end: 0.7 });
const t = useTexts(TEXTS, labels);
const list = blocks ?? presetBlocks(preset, t);
const sp = Math.max(0.1, speed);
const step = 1.05;
const t0 = 0.7;
const endAt = t0 + list.length * step;
const T = round((endAt + 2.8) / sp, 3);
const chipY = (i: number) => 56 + i * 64;
const scroll = mode === 'scroll';
const n = list.length;
// scroll: block i lands while the progress runs through its window
const win = (i: number) => ({ a: round((i / (n + 1)) * 0.95, 4), w: round(1 / (n + 1), 4) });
const local = (i: number) => `clamp(0, (var(--kit-progress, 1) - ${win(i).a}) / ${win(i).w}, 1)`;
const chips = list.map((b, i) => {
const cx = 20 + 85;
const cy = chipY(i) + 26;
const dx = round(b.slot.x + b.slot.w / 2 - cx, 1);
const dy = round(b.slot.y + b.slot.h / 2 - cy, 1);
const at = t0 + i * step;
return { b, i, dx, dy, at };
});
const scene = (
<FitCanvas width={CW} height={CH}>
<div key={play.cycle} className="relative size-full" aria-hidden>
{/* palette */}
<div className="absolute left-[12px] top-[12px] h-[476px] w-[186px] rounded-2xl border border-border bg-surface shadow-[var(--highlight)]" />
<p className="absolute left-[24px] top-[26px] text-[11px] font-semibold uppercase tracking-[0.08em] text-subtle-foreground">{t.palette}</p>
{chips.map(({ b, i }) => (
<div key={`slot${b.id}`} className="absolute left-[20px] flex h-[52px] w-[170px] items-center gap-2.5 rounded-xl border border-dashed border-border-strong px-3 text-[12.5px] text-subtle-foreground" style={{ top: chipY(i) }}>
<span className="grid size-7 place-items-center rounded-lg bg-muted">{b.icon}</span>
<span className="flex-1 truncate">{b.label}</span>
<svg viewBox="0 0 16 16" className="size-3.5 text-success" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3.5 8.5 6.5 11.5 12.5 5" /></svg>
</div>
))}
{/* browser */}
<div className="absolute overflow-hidden rounded-xl border border-border bg-background shadow-[var(--highlight),var(--shadow-lg)]" style={{ left: BROWSER.x, top: BROWSER.y, width: BROWSER.w, height: BROWSER.h }}>
<div className="flex h-8 items-center gap-2 border-b border-border bg-surface px-3">
<span className="flex gap-1.5">{[0, 1, 2].map((k) => <span key={k} className="size-2 rounded-full bg-border-strong" />)}</span>
<span className="mx-auto rounded-md bg-muted px-3 py-0.5 text-[10.5px] text-muted-foreground">{url}</span>
<span className="w-8" />
</div>
</div>
{/* empty slots */}
{list.map((b) => (
<div key={`e${b.id}`} className="absolute rounded-md border border-dashed border-border" style={{ left: b.slot.x, top: b.slot.y, width: b.slot.w, height: b.slot.h }} />
))}
{/* landed sections */}
{chips.map(({ b, i, at }) =>
scroll ? (
<div key={`c${b.id}`} className="absolute" style={{ left: b.slot.x, top: b.slot.y, width: b.slot.w, height: b.slot.h, opacity: `calc(${local(i)} * 1.6 - 0.6)`, transform: `scale(calc(0.96 + ${local(i)} * 0.04))` }}>
{b.content}
</div>
) : (
<Track
key={`c${b.id}`}
className="absolute"
style={{ left: b.slot.x, top: b.slot.y, width: b.slot.w, height: b.slot.h }}
frames={[
[0, 'opacity:0;clip-path:inset(0 0 100% 0 round 6px);transform:scale(.97)'],
[at + 0.62, 'opacity:0;clip-path:inset(0 0 100% 0 round 6px);transform:scale(.97);animation-timing-function:cubic-bezier(.2,.7,.2,1)'],
[at + 0.95, 'opacity:1;clip-path:inset(0 0 0 0 round 6px);transform:scale(1)'],
[endAt + 2.2, 'opacity:1;clip-path:inset(0 0 0 0 round 6px);transform:scale(1)'],
[endAt + 2.6, 'opacity:0;clip-path:inset(0 0 0 0 round 6px);transform:scale(1)'],
]}
end="opacity:1;clip-path:inset(0 0 0 0 round 6px);transform:none"
>
{b.content}
</Track>
),
)}
{/* landing flash */}
{!scroll &&
chips.map(({ b, at }) => (
<Track
key={`f${b.id}`}
className="pointer-events-none absolute rounded-md border-2 border-[var(--primary)] shadow-[0_0_0_4px_color-mix(in_oklab,var(--primary)_18%,transparent)]"
style={{ left: b.slot.x - 2, top: b.slot.y - 2, width: b.slot.w + 4, height: b.slot.h + 4 }}
frames={[[0, 'opacity:0'], [at + 0.6, 'opacity:0'], [at + 0.72, 'opacity:1'], [at + 1.5, 'opacity:0']]}
end="opacity:0"
/>
))}
{/* chips that fly */}
{chips.map(({ b, i, dx, dy, at }) => {
const chip = (
<div className="flex h-[52px] w-[170px] items-center gap-2.5 rounded-xl border border-border bg-card px-3 text-[12.5px] font-medium text-foreground shadow-[var(--highlight),var(--shadow-md)]">
<span className="grid size-7 place-items-center rounded-lg bg-[color-mix(in_oklab,var(--primary)_14%,transparent)] text-primary">{b.icon}</span>
<span className="flex-1 truncate">{b.label}</span>
<span className="flex flex-col gap-[3px]" aria-hidden>{[0, 1].map((k) => <span key={k} className="flex gap-[3px]">{[0, 1].map((m) => <span key={m} className="size-[3px] rounded-full bg-subtle-foreground" />)}</span>)}</span>
</div>
);
if (scroll)
return (
<div
key={`m${b.id}`}
className="absolute left-[20px] z-20"
style={{
top: chipY(i),
transform: `translate(calc(${dx}px * ${local(i)}), calc(${dy}px * ${local(i)})) scale(calc(1 - ${local(i)} * 0.12))`,
opacity: `calc(1.25 - ${local(i)} * 1.25)`,
}}
>
{chip}
</div>
);
return (
<Track
key={`m${b.id}`}
className="absolute left-[20px] z-20"
style={{ top: chipY(i) }}
frames={[
[0, 'opacity:1;transform:translate(0,0) scale(1) rotate(0deg)'],
[at, 'opacity:1;transform:translate(0,0) scale(1) rotate(0deg);animation-timing-function:cubic-bezier(.3,.7,.3,1)'],
[at + 0.16, 'opacity:1;transform:translate(4px,-4px) scale(1.05) rotate(-2deg);animation-timing-function:cubic-bezier(.6,0,.25,1)'],
[at + 0.66, `opacity:1;transform:translate(${dx}px,${dy}px) scale(.92) rotate(0deg)`],
[at + 0.82, `opacity:0;transform:translate(${dx}px,${dy}px) scale(.86) rotate(0deg)`],
[endAt + 2.5, `opacity:0;transform:translate(${dx}px,${dy}px) scale(.86) rotate(0deg)`],
[endAt + 2.51, 'opacity:0;transform:translate(0,0) scale(1) rotate(0deg)'],
[endAt + 2.8, 'opacity:1;transform:translate(0,0) scale(1) rotate(0deg)'],
]}
end={`opacity:0;transform:translate(${dx}px,${dy}px) scale(.86)`}
>
{chip}
</Track>
);
})}
{/* published */}
{scroll ? (
<div className="absolute right-[34px] top-[30px] z-30" style={{ opacity: `clamp(0, (var(--kit-progress, 1) - 0.94) * 16, 1)` }}>
<Published text={t.published} />
</div>
) : (
<Track
className="absolute right-[34px] top-[30px] z-30"
frames={[[0, 'opacity:0;transform:translateY(-8px) scale(.9)'], [endAt + 0.2, 'opacity:0;transform:translateY(-8px) scale(.9);animation-timing-function:cubic-bezier(.3,1.4,.5,1)'], [endAt + 0.6, 'opacity:1;transform:none'], [endAt + 2.2, 'opacity:1;transform:none'], [endAt + 2.5, 'opacity:0;transform:none']]}
end="opacity:1;transform:none"
>
<Published text={t.published} />
</Track>
)}
</div>
</FitCanvas>
);
return (
<SceneTimeline sid={sid} T={T} speed={sp} loop={loop}>
<div className={cn('relative w-full', className)}>
<div
ref={(el) => {
(play.ref as React.MutableRefObject<HTMLDivElement | null>).current = el;
(progress as React.MutableRefObject<HTMLDivElement | null>).current = el;
}}
data-kv={scroll ? 'off' : play.state}
role={label ? 'img' : undefined}
aria-label={label}
className={cn(sid, 'relative w-full')}
>
<style dangerouslySetInnerHTML={{ __html: SCENE_CSS }} />
{scene}
</div>
{controls && !scroll && (
<div className="mt-2 flex justify-end">
<PlayToggle inline state={play.state} reduced={play.reduced} onToggle={play.toggle} />
</div>
)}
</div>
</SceneTimeline>
);
}
function Published({ text }: { text: string }) {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-success px-2.5 py-1 text-[11.5px] font-semibold text-[oklch(0.99_0_none)] shadow-[var(--shadow-md)]">
<svg viewBox="0 0 16 16" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden><path d="M3.5 8.5 6.5 11.5 12.5 5" /></svg>
{text}
</span>
);
}Frequent questions
How do I install Page assembly?
With aiw add @ai-website/page-assembly. The command puts components/ui/page-assembly.tsx into your project, the code is yours from then on. It brings along motion and motion-scene. It needs no further npm packages.
Which variants does Page assembly have?
3 variants, Landing page, App screen and With the scroll.
Can I translate the texts of Page assembly?
Yes. Page assembly carries 13 texts of its own in English and German, such as “Components”, “Published” and “Navbar”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Page assembly?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Page assembly with aiw_components and install it with aiw_add @ai-website/page-assembly.