AI Design Tool Landing New
A Figma-style landing page with a live, interactive canvas editor, AI generation, multiplayer, prototyping and Dev Mode.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/landing-design-tool-landing.json02
Or copy the source below (11 files). Dependencies: lucide-react.
Source
components/niceui/pages/landing/LandingPageLattice.tsx
import { AiFeatures } from './lattice/AiFeatures';
import { LatticeCta, LatticeFooter, LatticePricing, Wall } from './lattice/Closing';
import { DevMode, Multiplayer, Prototyping } from './lattice/Collaboration';
import { LatticeHero, LatticeNav } from './lattice/Hero';
export function LandingPageLattice() {
return (
<div className="min-h-screen bg-white font-sans text-zinc-950 antialiased selection:bg-[#0d99ff]/20 selection:text-zinc-950">
<LatticeNav />
<LatticeHero />
<AiFeatures />
<Multiplayer />
<Prototyping />
<DevMode />
<LatticePricing />
<Wall />
<LatticeCta />
<LatticeFooter />
</div>
);
}components/niceui/pages/landing/lattice/AiFeatures.tsx
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { ArrowDown, ArrowRight, Bell, LayoutGrid, Palette, RotateCcw, Sparkles, Wand2 } from 'lucide-react';
import { cn } from '@/lib/cn';
import { SectionHead } from './ui';
const PROMPT = 'A mobile onboarding screen for a meditation app, warm and calm';
function PromptToUI() {
const [typed, setTyped] = useState(0);
const [run, setRun] = useState(0);
useEffect(() => {
setTyped(0);
const id = setInterval(() => setTyped((n) => (n >= PROMPT.length ? n : n + 1)), 28);
return () => clearInterval(id);
}, [run]);
const done = typed >= PROMPT.length;
const blocks = [
{ cls: 'h-40 rounded-2xl bg-gradient-to-br from-orange-300 to-amber-200', label: 'Illustration' },
{ cls: 'h-6 w-3/4 rounded-md bg-zinc-900', label: 'Heading' },
{ cls: 'h-3 w-full rounded bg-zinc-300', label: 'Body' },
{ cls: 'h-3 w-2/3 rounded bg-zinc-300', label: 'Body' },
{ cls: 'mt-4 h-11 rounded-xl bg-zinc-900', label: 'Button' },
];
return (
<div className="grid h-full gap-8 lg:grid-cols-2">
<div className="flex flex-col">
<div className="rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm">
<div className="flex items-center gap-2 text-xs font-medium text-zinc-500"><Sparkles className="h-3.5 w-3.5 text-[#0d99ff]" /> Prompt</div>
<p className="mt-3 min-h-[56px] text-lg leading-snug text-zinc-900">
{PROMPT.slice(0, typed)}
{!done && <span className="ml-0.5 inline-block h-5 w-0.5 translate-y-1 animate-blink bg-[#0d99ff]" />}
</p>
</div>
<div className="mt-4 space-y-2">
{['Reading brand tokens', 'Composing layout', 'Applying auto layout', 'Writing copy'].map((s, i) => {
const on = done && i < 4;
return (
<div key={s} className={cn('flex items-center gap-3 rounded-xl px-3 py-2 text-sm transition-all duration-500', on ? 'bg-emerald-50 text-emerald-800' : 'text-zinc-400')} style={{ transitionDelay: `${i * 180}ms` }}>
<span className={cn('h-1.5 w-1.5 rounded-full transition-colors', on ? 'bg-emerald-500' : 'bg-zinc-300')} style={{ transitionDelay: `${i * 180}ms` }} />
{s}
</div>
);
})}
</div>
<button onClick={() => setRun((r) => r + 1)} className="mt-auto inline-flex w-fit items-center gap-2 pt-6 text-sm font-medium text-zinc-600 hover:text-zinc-950">
<RotateCcw className="h-4 w-4" /> Replay
</button>
</div>
<div className="grid place-items-center rounded-2xl bg-zinc-100 p-6" style={{ backgroundImage: 'radial-gradient(#d4d4d8 1px, transparent 1px)', backgroundSize: '14px 14px' }}>
<div className="w-60 rounded-[28px] border-[6px] border-zinc-900 bg-[#fffaf3] p-4 shadow-2xl">
<div className="mx-auto mb-4 h-1.5 w-12 rounded-full bg-zinc-200" />
<div className="space-y-2.5">
{blocks.map((b, i) => (
<div
key={`${run}-${i}`}
className={cn(b.cls, 'transition-all duration-700 ease-spring', done ? 'scale-100 opacity-100' : 'scale-95 opacity-0')}
style={{ transitionDelay: done ? `${700 + i * 120}ms` : '0ms' }}
/>
))}
</div>
</div>
</div>
</div>
);
}
function AutoLayout() {
const [gap, setGap] = useState(12);
const [pad, setPad] = useState(16);
const [dir, setDir] = useState<'row' | 'col'>('col');
const items = [Bell, LayoutGrid, Palette];
return (
<div className="grid h-full gap-8 lg:grid-cols-[1fr_1.3fr]">
<div className="space-y-6 rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm">
<div>
<span className="text-xs font-medium text-zinc-500">Direction</span>
<div className="mt-2 flex gap-1 rounded-lg bg-zinc-100 p-1">
{(['col', 'row'] as const).map((d) => (
<button key={d} onClick={() => setDir(d)} aria-label={d === 'col' ? 'Vertical' : 'Horizontal'} className={cn('grid h-8 flex-1 place-items-center rounded-md transition-all', dir === d ? 'bg-white text-zinc-900 shadow-sm' : 'text-zinc-400 hover:text-zinc-700')}>
{d === 'col' ? <ArrowDown className="h-4 w-4" /> : <ArrowRight className="h-4 w-4" />}
</button>
))}
</div>
</div>
{[
{ label: 'Gap', v: gap, set: setGap, max: 40 },
{ label: 'Padding', v: pad, set: setPad, max: 40 },
].map((c) => (
<div key={c.label}>
<div className="flex justify-between text-xs"><span className="font-medium text-zinc-500">{c.label}</span><span className="font-mono text-zinc-900">{c.v}px</span></div>
<input type="range" min={0} max={c.max} value={c.v} onChange={(e) => c.set(Number(e.target.value))} className="mt-2 w-full accent-[#0d99ff]" />
</div>
))}
<p className="text-sm leading-relaxed text-zinc-600">Layouts that behave like code. Resize anything and Lattice reflows it, exactly as CSS flexbox would.</p>
</div>
<div className="grid min-h-[320px] place-items-center rounded-2xl bg-zinc-100 p-6">
<div className="relative rounded-xl border-[1.5px] border-[#0d99ff] bg-white transition-all duration-300 ease-smooth" style={{ padding: pad }}>
<span className="absolute -top-6 left-0 rounded bg-[#0d99ff] px-1.5 py-0.5 font-mono text-[10px] text-white">Auto layout</span>
<span className="pointer-events-none absolute inset-x-0 top-0 bg-rose-400/20 transition-all duration-300" style={{ height: pad }} />
<div className={cn('flex transition-all duration-300', dir === 'col' ? 'flex-col' : 'flex-row')} style={{ gap }}>
{items.map((Icon, i) => (
<div key={i} className="flex items-center gap-3 rounded-lg border border-zinc-200 bg-zinc-50 px-4 py-3 transition-all duration-300">
<Icon className="h-4 w-4 text-zinc-700" />
<span className="whitespace-nowrap text-sm text-zinc-800">Item {i + 1}</span>
</div>
))}
</div>
</div>
</div>
</div>
);
}
const themes = [
{ name: 'Azure', primary: '#0d99ff', surface: '#f0f8ff', text: '#0b2540', radius: 14 },
{ name: 'Ember', primary: '#ea580c', surface: '#fff7ed', text: '#431407', radius: 6 },
{ name: 'Forest', primary: '#059669', surface: '#ecfdf5', text: '#022c22', radius: 22 },
{ name: 'Graphite', primary: '#18181b', surface: '#fafafa', text: '#09090b', radius: 2 },
];
function Tokens() {
const [t, setT] = useState(themes[0]);
return (
<div className="grid h-full gap-8 lg:grid-cols-[1fr_1.3fr]">
<div className="space-y-2">
{themes.map((th) => (
<button key={th.name} onClick={() => setT(th)} className={cn('flex w-full items-center gap-4 rounded-xl border p-3 text-left transition-all', t.name === th.name ? 'border-zinc-900 bg-white shadow-sm' : 'border-zinc-200 bg-white/60 hover:border-zinc-300')}>
<span className="flex -space-x-1">
{[th.primary, th.surface, th.text].map((c) => <span key={c} className="h-6 w-6 rounded-full ring-2 ring-white" style={{ background: c }} />)}
</span>
<span className="text-sm font-medium text-zinc-900">{th.name}</span>
<span className="ml-auto font-mono text-xs text-zinc-400">{th.primary}</span>
</button>
))}
</div>
<div className="grid min-h-[320px] place-items-center rounded-2xl p-6 transition-colors duration-500" style={{ background: t.surface }}>
<div className="w-full max-w-xs bg-white p-6 shadow-xl transition-all duration-500" style={{ borderRadius: t.radius, color: t.text }}>
<div className="grid h-10 w-10 place-items-center text-white transition-all duration-500" style={{ background: t.primary, borderRadius: t.radius * 0.6 }}><Wand2 className="h-5 w-5" /></div>
<h4 className="mt-5 text-xl font-semibold tracking-tight">Weekly digest</h4>
<p className="mt-1 text-sm opacity-70">Every token updates across 1,240 components instantly.</p>
<button className="mt-6 h-10 w-full text-sm font-medium text-white transition-all duration-500" style={{ background: t.primary, borderRadius: t.radius * 0.7 }}>Subscribe</button>
</div>
</div>
</div>
);
}
const tabs = [
{ id: 'prompt', label: 'Prompt to UI', icon: Sparkles, el: PromptToUI },
{ id: 'layout', label: 'Auto layout', icon: LayoutGrid, el: AutoLayout },
{ id: 'tokens', label: 'Design tokens', icon: Palette, el: Tokens },
];
export function AiFeatures() {
const [active, setActive] = useState(0);
const refs = useRef<Array<HTMLButtonElement | null>>([]);
const [pill, setPill] = useState({ left: 0, width: 0 });
useLayoutEffect(() => {
const el = refs.current[active];
if (el) setPill({ left: el.offsetLeft, width: el.offsetWidth });
}, [active]);
const Active = tabs[active].el;
return (
<section id="lattice-ai" className="scroll-mt-16 bg-zinc-50 py-28">
<div className="mx-auto max-w-6xl px-6">
<SectionHead
center tag="Lattice AI" color="#0d99ff"
title={<>Your newest teammate <span className="text-zinc-400">never sleeps.</span></>}
body="AI that understands your design system. It drafts, rearranges and tidies, then hands the pen right back."
/>
<div className="mt-12 flex justify-center">
<div className="relative flex rounded-xl border border-zinc-200 bg-white p-1 shadow-sm">
<span className="absolute inset-y-1 rounded-lg bg-zinc-950 transition-all duration-500 ease-spring" style={pill} />
{tabs.map((t, i) => (
<button key={t.id} ref={(el) => { refs.current[i] = el; }} onClick={() => setActive(i)} className={cn('relative z-10 flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-colors duration-300', active === i ? 'text-white' : 'text-zinc-600 hover:text-zinc-950')}>
<t.icon className="h-4 w-4" /><span className="hidden sm:inline">{t.label}</span>
</button>
))}
</div>
</div>
<div key={active} className="mt-10 rounded-3xl border border-zinc-200 bg-white/60 p-4 animate-fade-up md:p-8">
<Active />
</div>
</div>
</section>
);
}components/niceui/pages/landing/lattice/ui.tsx
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { cn } from '@/lib/cn';
import { Reveal } from '@/components/niceui/pages/landing/shared/motion';
export const selectionBlue = '#0d99ff';
export function scrollToSection(id: string) {
document.getElementById(`lattice-${id}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
type BtnProps = ButtonHTMLAttributes<HTMLButtonElement> & { variant?: 'dark' | 'light' | 'blue' | 'ghost' };
export function LButton({ variant = 'dark', className, children, ...rest }: BtnProps) {
return (
<button
{...rest}
className={cn(
'inline-flex h-11 items-center justify-center gap-2 rounded-xl px-5 text-sm font-medium transition-all duration-200 active:scale-[0.97] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0d99ff]/50 focus-visible:ring-offset-2',
variant === 'dark' && 'bg-zinc-950 text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.15),0_1px_2px_rgba(0,0,0,0.2)] hover:bg-zinc-800',
variant === 'light' && 'bg-white text-zinc-900 ring-1 ring-zinc-200 hover:bg-zinc-50 hover:ring-zinc-300',
variant === 'blue' && 'bg-[#0d99ff] text-white hover:bg-[#0b87e0]',
variant === 'ghost' && 'text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900',
className,
)}
>
{children}
</button>
);
}
export function SelectionBox({ children, label, color = selectionBlue, className }: { children: ReactNode; label?: string; color?: string; className?: string }) {
return (
<span className={cn('relative inline-block px-2', className)}>
<span className="pointer-events-none absolute inset-0 border-[1.5px]" style={{ borderColor: color, background: `${color}10` }}>
{['-left-[5px] -top-[5px]', '-right-[5px] -top-[5px]', '-left-[5px] -bottom-[5px]', '-right-[5px] -bottom-[5px]'].map((p) => (
<span key={p} className={cn('absolute h-2 w-2 border-[1.5px] bg-white', p)} style={{ borderColor: color }} />
))}
</span>
{label && (
<span className="absolute -top-7 left-0 rounded px-1.5 py-0.5 font-mono text-[10px] font-medium tracking-normal text-white" style={{ background: color }}>{label}</span>
)}
<span className="relative">{children}</span>
</span>
);
}
export function SectionHead({ tag, color, title, body, center }: { tag: string; color: string; title: ReactNode; body?: string; center?: boolean }) {
return (
<Reveal className={cn('max-w-3xl', center && 'mx-auto text-center')}>
<span className="inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white px-3 py-1 font-mono text-[11px] uppercase tracking-wider text-zinc-600">
<span className="h-2 w-2 rounded-[2px]" style={{ background: color }} />
{tag}
</span>
<h2 className="mt-6 text-4xl font-semibold leading-[1.05] tracking-[-0.035em] text-zinc-950 md:text-6xl">{title}</h2>
{body && <p className={cn('mt-5 max-w-xl text-lg leading-relaxed text-zinc-600', center && 'mx-auto')}>{body}</p>}
</Reveal>
);
}components/niceui/pages/landing/shared/motion.tsx
import { type ReactNode, useEffect, useRef, useState } from 'react';
import { cn } from '@/lib/cn';
export function useInView<T extends Element>(threshold = 0.2) {
const ref = useRef<T>(null);
const [inView, setInView] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setInView(true);
io.disconnect();
}
},
{ threshold },
);
io.observe(el);
return () => io.disconnect();
}, [threshold]);
return { ref, inView };
}
export function Reveal({ children, delay = 0, className }: { children: ReactNode; delay?: number; className?: string }) {
const { ref, inView } = useInView<HTMLDivElement>(0.15);
return (
<div
ref={ref}
className={cn('transition-all duration-1000 ease-smooth', inView ? 'translate-y-0 opacity-100 blur-0' : 'translate-y-6 opacity-0 blur-[2px]', className)}
style={{ transitionDelay: `${delay}ms` }}
>
{children}
</div>
);
}
export function useCountUp(target: number, active: boolean, duration = 1800) {
const [val, setVal] = useState(0);
useEffect(() => {
if (!active) return;
let raf = 0;
const start = performance.now();
const loop = (now: number) => {
const t = Math.min(1, (now - start) / duration);
setVal(target * (1 - Math.pow(2, -10 * t)));
if (t < 1) raf = requestAnimationFrame(loop);
else setVal(target);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [target, active, duration]);
return val;
}components/niceui/pages/landing/lattice/Closing.tsx
import { useState, type FormEvent } from 'react';
import { ArrowRight, Check, Github, Linkedin, Loader2, Shapes, Twitter, Youtube } from 'lucide-react';
import { cn } from '@/lib/cn';
import { Reveal } from '@/components/niceui/pages/landing/shared/motion';
import { LButton, SectionHead, SelectionBox, scrollToSection } from './ui';
const plans = [
{ name: 'Starter', seat: 0, blurb: 'For personal projects', features: ['3 design files', 'Unlimited viewers', '50 AI generations / mo', 'Community plugins'] },
{ name: 'Professional', seat: 15, blurb: 'For growing product teams', features: ['Unlimited files & projects', 'Dev Mode + code export', '2,000 AI generations / mo', 'Shared libraries', 'Version history'], featured: true },
{ name: 'Organization', seat: 45, blurb: 'For design systems at scale', features: ['Everything in Professional', 'Unlimited AI', 'Branching & merging', 'SSO / SCIM', 'Design system analytics'] },
];
export function LatticePricing() {
const [seats, setSeats] = useState(8);
return (
<section id="lattice-pricing" className="scroll-mt-16 py-28">
<div className="mx-auto max-w-6xl px-6">
<SectionHead center tag="Pricing" color="#facc15" title={<>Start free. <span className="text-zinc-400">Scale when it clicks.</span></>} />
<Reveal className="mx-auto mt-12 max-w-md rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm">
<div className="flex items-center justify-between text-sm">
<span className="font-medium text-zinc-700">Editors on your team</span>
<span className="rounded-md bg-zinc-950 px-2 py-0.5 font-mono text-xs text-white">{seats}</span>
</div>
<input type="range" min={1} max={50} value={seats} onChange={(e) => setSeats(Number(e.target.value))} aria-label="Number of editors" className="mt-4 w-full accent-[#0d99ff]" />
<div className="mt-1 flex justify-between font-mono text-[10px] text-zinc-400"><span>1</span><span>25</span><span>50</span></div>
</Reveal>
<div className="mt-10 grid gap-4 lg:grid-cols-3">
{plans.map((p, i) => {
const total = p.seat * seats;
return (
<Reveal key={p.name} delay={i * 100}>
<div className={cn('relative flex h-full flex-col rounded-3xl p-8 transition-all duration-300 hover:-translate-y-1', p.featured ? 'bg-zinc-950 text-white shadow-[0_40px_80px_-30px_rgba(13,153,255,0.45)]' : 'border border-zinc-200 bg-white text-zinc-950')}>
{p.featured && <span className="absolute -top-3 left-8 rounded-full bg-[#0d99ff] px-3 py-1 text-xs font-medium text-white">Most popular</span>}
<h3 className="text-lg font-semibold">{p.name}</h3>
<p className={cn('text-sm', p.featured ? 'text-zinc-400' : 'text-zinc-500')}>{p.blurb}</p>
<div className="mt-8 flex items-baseline gap-1.5">
<span key={total} className="text-5xl font-semibold tabular-nums tracking-tight animate-fade-up">${total.toLocaleString()}</span>
<span className={cn('text-sm', p.featured ? 'text-zinc-400' : 'text-zinc-500')}>/ month</span>
</div>
<p className={cn('mt-1 font-mono text-xs', p.featured ? 'text-zinc-500' : 'text-zinc-400')}>{p.seat ? `$${p.seat} × ${seats} editors` : 'Free forever'}</p>
<ul className="mt-8 flex-1 space-y-3">
{p.features.map((f) => (
<li key={f} className="flex items-center gap-3 text-sm">
<Check className={cn('h-4 w-4 shrink-0', p.featured ? 'text-[#0d99ff]' : 'text-emerald-600')} />
<span className={p.featured ? 'text-zinc-300' : 'text-zinc-700'}>{f}</span>
</li>
))}
</ul>
<LButton variant={p.featured ? 'blue' : 'light'} className="mt-10 w-full" onClick={() => scrollToSection('cta')}>
{p.seat ? `Get ${p.name}` : 'Start for free'}
</LButton>
</div>
</Reveal>
);
})}
</div>
</div>
</section>
);
}
const quotes = [
{ q: 'We replaced three tools with Lattice. Our design-to-dev handoff went from days to minutes.', n: 'Sana Okafor', r: 'Head of Design, Northwind', c: '#0d99ff' },
{ q: 'The AI actually respects our tokens. It is the first generator that outputs things we would ship.', n: 'Luca Ferri', r: 'Design Systems Lead, Initech', c: '#f97316' },
{ q: 'Branching changed how we work. Ten designers, one library, zero overwrites.', n: 'Hana Ito', r: 'VP Product, Lumon', c: '#10b981' },
{ q: 'Dev Mode is the reason our engineers open design files voluntarily now.', n: 'Marcus Webb', r: 'Staff Engineer, Hooli', c: '#f43f5e' },
{ q: 'Prototypes are so close to real that we run usability tests straight from the share link.', n: 'Elena Duarte', r: 'UX Research, Vandelay', c: '#facc15' },
{ q: 'Performance on a 4,000-frame file is unreal. It just does not slow down.', n: 'Dev Patel', r: 'Founder, Pied Piper', c: '#0d99ff' },
];
export function Wall() {
return (
<section className="bg-zinc-50 py-28">
<div className="mx-auto max-w-6xl px-6">
<SectionHead tag="Loved by teams" color="#f97316" title={<>Made by makers, <span className="text-zinc-400">for makers.</span></>} />
<div className="mt-14 columns-1 gap-4 md:columns-2 lg:columns-3">
{quotes.map((t, i) => (
<Reveal key={t.n} delay={(i % 3) * 100} className="mb-4 break-inside-avoid">
<figure className="group rounded-2xl border border-zinc-200 bg-white p-6 transition-all duration-300 hover:-translate-y-0.5 hover:border-zinc-300 hover:shadow-lg">
<blockquote className="text-[15px] leading-relaxed text-zinc-800">"{t.q}"</blockquote>
<figcaption className="mt-6 flex items-center gap-3">
<span className="grid h-9 w-9 place-items-center rounded-full text-sm font-semibold text-white" style={{ background: t.c }}>{t.n[0]}</span>
<div>
<div className="text-sm font-medium text-zinc-950">{t.n}</div>
<div className="text-xs text-zinc-500">{t.r}</div>
</div>
</figcaption>
</figure>
</Reveal>
))}
</div>
</div>
</section>
);
}
export function LatticeCta() {
const [email, setEmail] = useState('');
const [state, setState] = useState<'idle' | 'error' | 'loading' | 'done'>('idle');
const submit = (e: FormEvent) => {
e.preventDefault();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) return setState('error');
setState('loading');
window.setTimeout(() => setState('done'), 1100);
};
return (
<section id="lattice-cta" className="scroll-mt-16 px-6 py-28">
<Reveal className="relative mx-auto max-w-5xl overflow-hidden rounded-[32px] bg-zinc-950 px-8 py-20 text-center md:py-24">
<div className="pointer-events-none absolute inset-0 opacity-40" style={{ backgroundImage: 'linear-gradient(rgba(255,255,255,0.06) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.06) 1px, transparent 1px)', backgroundSize: '40px 40px' }} />
<div className="pointer-events-none absolute -top-24 left-1/2 h-64 w-[600px] -translate-x-1/2 rounded-full bg-[#0d99ff]/30 blur-[100px]" />
<div className="relative">
<h2 className="text-4xl font-semibold leading-[1.05] tracking-[-0.04em] text-white md:text-6xl">
Your next idea <br className="hidden sm:block" />deserves a <SelectionBox label="canvas" className="text-white">canvas.</SelectionBox>
</h2>
<p className="mx-auto mt-6 max-w-md text-zinc-400">Join 4 million designers and developers building on Lattice.</p>
{state === 'done' ? (
<div className="mx-auto mt-10 inline-flex items-center gap-2 rounded-xl bg-white px-5 py-3 text-sm font-medium text-zinc-950 animate-scale-in">
<Check className="h-4 w-4 text-emerald-600" /> Invite sent. Check your inbox.
</div>
) : (
<form onSubmit={submit} noValidate className="mx-auto mt-10 flex max-w-md flex-col gap-2 sm:flex-row">
<input
value={email}
onChange={(e) => { setEmail(e.target.value); if (state === 'error') setState('idle'); }}
type="email" placeholder="you@company.com" aria-label="Work email"
className={cn('h-12 flex-1 rounded-xl bg-white/5 px-4 text-sm text-white outline-none ring-1 transition-all placeholder:text-zinc-500 focus:bg-white/10', state === 'error' ? 'ring-rose-500' : 'ring-white/15 focus:ring-[#0d99ff]')}
/>
<LButton variant="blue" type="submit" className="h-12" disabled={state === 'loading'}>
{state === 'loading' ? <Loader2 className="h-4 w-4 animate-spin" /> : <>Get started <ArrowRight className="h-4 w-4" /></>}
</LButton>
</form>
)}
<p className={cn('mt-3 text-sm text-rose-400 transition-opacity', state === 'error' ? 'opacity-100' : 'opacity-0')} role="alert">Enter a valid work email.</p>
</div>
</Reveal>
</section>
);
}
const footerCols = [
{ t: 'Product', l: ['Design', 'Lattice AI', 'Prototyping', 'Dev Mode', 'Whiteboard'] },
{ t: 'Resources', l: ['Community', 'Plugins', 'Templates', 'Changelog', 'Status'] },
{ t: 'Company', l: ['About', 'Careers', 'Customers', 'Press', 'Contact'] },
];
export function LatticeFooter() {
return (
<footer className="border-t border-zinc-200 bg-white">
<div className="mx-auto grid max-w-7xl gap-12 px-6 py-16 md:grid-cols-[1.5fr_repeat(3,1fr)]">
<div>
<div className="flex items-center gap-2.5">
<span className="grid h-8 w-8 place-items-center rounded-lg bg-zinc-950 text-white"><Shapes className="h-4 w-4" /></span>
<span className="text-[17px] font-semibold tracking-tight text-zinc-950">Lattice</span>
</div>
<p className="mt-4 max-w-xs text-sm leading-relaxed text-zinc-600">The AI-native design canvas for teams who ship.</p>
<div className="mt-6 flex gap-2">
{[Twitter, Github, Linkedin, Youtube].map((Icon, i) => (
<button key={i} aria-label="Social link" className="grid h-9 w-9 place-items-center rounded-lg border border-zinc-200 text-zinc-500 transition-all hover:-translate-y-0.5 hover:border-zinc-300 hover:text-zinc-950"><Icon className="h-4 w-4" /></button>
))}
</div>
</div>
{footerCols.map((c) => (
<div key={c.t}>
<h4 className="font-mono text-[11px] uppercase tracking-wider text-zinc-400">{c.t}</h4>
<ul className="mt-4 space-y-2.5">
{c.l.map((l) => <li key={l}><button className="text-sm text-zinc-600 transition-colors hover:text-zinc-950">{l}</button></li>)}
</ul>
</div>
))}
</div>
<div className="border-t border-zinc-200">
<div className="mx-auto flex max-w-7xl flex-col justify-between gap-3 px-6 py-6 text-xs text-zinc-500 sm:flex-row">
<span>© 2026 Lattice Design, Inc. A NiceUI template.</span>
<span className="flex items-center gap-2"><span className="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" /> All systems operational</span>
</div>
</div>
</footer>
);
}components/niceui/pages/landing/lattice/Collaboration.tsx
import { useEffect, useRef, useState, type FormEvent } from 'react';
import { Eye, MousePointer2, Play, Send, Smartphone, Sparkles } from 'lucide-react';
import { cn } from '@/lib/cn';
import { CodeBlock } from '@/components/niceui/ui/CodeBlock';
import { Reveal } from '@/components/niceui/pages/landing/shared/motion';
import { SectionHead, selectionBlue } from './ui';
const people = [
{ name: 'Mira Chen', role: 'Product design', color: '#f97316', path: [[58, 22], [70, 55], [42, 60], [60, 30]] },
{ name: 'Theo Park', role: 'Engineering', color: '#10b981', path: [[22, 70], [35, 35], [20, 48], [30, 78]] },
{ name: 'Ava Rossi', role: 'Brand', color: '#f43f5e', path: [[80, 75], [62, 82], [84, 40], [74, 66]] },
];
type Thread = { id: number; x: number; y: number; author: string; color: string; messages: string[] };
const initialThreads: Thread[] = [
{ id: 1, x: 30, y: 26, author: 'Mira', color: '#f97316', messages: ['Can we try the headline at 56px? Feels a touch small on desktop.'] },
{ id: 2, x: 68, y: 64, author: 'Theo', color: '#10b981', messages: ['This chart maps to <Sparkline />, already in the library.'] },
];
function Cursor({ color, name, path }: { color: string; name: string; path: number[][] }) {
const [i, setI] = useState(0);
useEffect(() => {
const id = setInterval(() => setI((n) => (n + 1) % path.length), 2600 + name.length * 120);
return () => clearInterval(id);
}, [path.length, name.length]);
return (
<div className="pointer-events-none absolute z-20 transition-all duration-[2400ms] ease-smooth" style={{ left: `${path[i][0]}%`, top: `${path[i][1]}%` }}>
<MousePointer2 className="h-5 w-5 -rotate-12" style={{ color, fill: color }} />
<span className="ml-4 inline-block rounded-full px-2 py-0.5 text-[11px] font-medium text-white" style={{ background: color }}>{name.split(' ')[0]}</span>
</div>
);
}
export function Multiplayer() {
const [threads, setThreads] = useState(initialThreads);
const [open, setOpen] = useState<number | null>(1);
const [draft, setDraft] = useState('');
const reply = (e: FormEvent) => {
e.preventDefault();
if (!draft.trim() || open === null) return;
setThreads((ts) => ts.map((t) => (t.id === open ? { ...t, messages: [...t.messages, draft.trim()] } : t)));
setDraft('');
};
return (
<section id="lattice-multiplayer" className="scroll-mt-16 bg-zinc-950 py-28 text-white">
<div className="mx-auto grid max-w-6xl items-center gap-14 px-6 lg:grid-cols-[1fr_1.3fr]">
<Reveal>
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 font-mono text-[11px] uppercase tracking-wider text-zinc-300">
<span className="h-2 w-2 rounded-[2px] bg-orange-500" /> Multiplayer
</span>
<h2 className="mt-6 text-4xl font-semibold leading-[1.05] tracking-[-0.035em] md:text-6xl">One file. <span className="text-zinc-500">Everyone in it.</span></h2>
<p className="mt-5 max-w-md text-lg leading-relaxed text-zinc-400">Live cursors, threaded comments and follow mode. Feedback happens on the work, not in another tab.</p>
<div className="mt-10 space-y-2">
{people.map((p) => (
<div key={p.name} className="flex items-center gap-3 rounded-xl border border-white/5 bg-white/[0.03] p-3 transition-colors hover:bg-white/[0.06]">
<span className="relative grid h-9 w-9 place-items-center rounded-full text-sm font-semibold" style={{ background: p.color }}>
{p.name[0]}
<span className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-zinc-950 bg-emerald-400" />
</span>
<div>
<div className="text-sm font-medium">{p.name}</div>
<div className="text-xs text-zinc-500">{p.role}</div>
</div>
<button className="ml-auto flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs text-zinc-400 transition-colors hover:bg-white/10 hover:text-white"><Eye className="h-3.5 w-3.5" /> Follow</button>
</div>
))}
</div>
</Reveal>
<Reveal delay={150}>
<div className="relative aspect-[4/3] overflow-hidden rounded-3xl border border-white/10 bg-zinc-900" style={{ backgroundImage: 'radial-gradient(rgba(255,255,255,0.08) 1px, transparent 1px)', backgroundSize: '18px 18px' }}>
<div className="absolute left-[12%] top-[14%] w-[52%] rounded-2xl bg-white p-5 text-zinc-900 shadow-2xl">
<div className="h-3 w-16 rounded bg-orange-500" />
<div className="mt-4 h-5 w-4/5 rounded bg-zinc-900" />
<div className="mt-2 h-2.5 w-full rounded bg-zinc-200" />
<div className="mt-1.5 h-2.5 w-2/3 rounded bg-zinc-200" />
<div className="mt-5 flex gap-2"><div className="h-8 w-24 rounded-lg bg-zinc-900" /><div className="h-8 w-20 rounded-lg ring-1 ring-zinc-300" /></div>
</div>
<div className="absolute bottom-[12%] right-[10%] w-[42%] rounded-2xl border border-white/10 bg-zinc-800 p-4">
<div className="flex h-20 items-end gap-1.5">
{[30, 55, 40, 70, 50, 85, 65].map((h, i) => <div key={i} className="flex-1 rounded-t bg-emerald-400/80 animate-bar-bounce" style={{ height: `${h}%`, animationDelay: `${i * 120}ms` }} />)}
</div>
</div>
{people.map((p) => <Cursor key={p.name} {...p} />)}
{threads.map((t) => (
<div key={t.id} className="absolute z-30" style={{ left: `${t.x}%`, top: `${t.y}%` }}>
<button
onClick={() => setOpen(open === t.id ? null : t.id)}
aria-label={`Comment by ${t.author}`}
className={cn('grid h-8 w-8 place-items-center rounded-full rounded-bl-none text-xs font-semibold text-white shadow-lg ring-2 ring-white transition-transform hover:scale-110', open === t.id && 'scale-110')}
style={{ background: t.color }}
>
{t.messages.length > 1 ? t.messages.length : t.author[0]}
</button>
{open === t.id && (
<div className={cn('absolute top-10 w-64 rounded-xl bg-white p-3 text-zinc-900 shadow-2xl animate-scale-in', t.x > 50 ? 'right-0 origin-top-right' : 'left-0 origin-top-left')}>
<div className="max-h-32 space-y-2 overflow-y-auto">
{t.messages.map((m, i) => (
<div key={i} className="text-xs leading-relaxed">
<span className="font-semibold">{i === 0 ? t.author : 'You'}</span>
<p className="text-zinc-600">{m}</p>
</div>
))}
</div>
<form onSubmit={reply} className="mt-3 flex items-center gap-1.5 rounded-lg bg-zinc-100 p-1 pl-2.5">
<input value={draft} onChange={(e) => setDraft(e.target.value)} placeholder="Reply..." aria-label="Reply" className="min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-zinc-400" />
<button aria-label="Send reply" className="grid h-6 w-6 place-items-center rounded-md bg-[#0d99ff] text-white disabled:opacity-40" disabled={!draft.trim()}><Send className="h-3 w-3" /></button>
</form>
</div>
)}
</div>
))}
<div className="absolute inset-x-0 top-0 flex items-center justify-center gap-2 bg-orange-500 py-1.5 text-xs font-medium text-white">
<Eye className="h-3.5 w-3.5" /> Following Mira Chen
</div>
</div>
</Reveal>
</div>
</section>
);
}
const screens = [
{ title: 'Welcome', tint: 'from-sky-100 to-white', accent: 'bg-[#0d99ff]' },
{ title: 'Choose plan', tint: 'from-orange-100 to-white', accent: 'bg-orange-500' },
{ title: 'All set', tint: 'from-emerald-100 to-white', accent: 'bg-emerald-500' },
];
export function Prototyping() {
const [step, setStep] = useState(-1);
const timer = useRef<number>();
useEffect(() => () => clearTimeout(timer.current), []);
const play = () => {
clearTimeout(timer.current);
const tick = (n: number) => {
setStep(n);
if (n < screens.length - 1) timer.current = window.setTimeout(() => tick(n + 1), 1100);
else timer.current = window.setTimeout(() => setStep(-1), 1600);
};
tick(0);
};
return (
<section id="lattice-prototype" className="scroll-mt-16 py-28">
<style>{`@keyframes lattice-dash{to{stroke-dashoffset:-24}}`}</style>
<div className="mx-auto max-w-6xl px-6">
<div className="flex flex-col justify-between gap-8 md:flex-row md:items-end">
<SectionHead tag="Prototyping" color="#10b981" title={<>Wire it up. <span className="text-zinc-400">Watch it move.</span></>} body="Connect frames with a drag, add smart animate, and share a link that feels like the real app." />
<button onClick={play} className="inline-flex h-11 shrink-0 items-center gap-2 rounded-xl bg-emerald-600 px-5 text-sm font-medium text-white transition-all hover:bg-emerald-500 active:scale-[0.97]">
<Play className="h-4 w-4 fill-current" /> Play prototype
</button>
</div>
<Reveal className="relative mt-16">
<svg className="pointer-events-none absolute inset-0 hidden h-full w-full md:block" viewBox="0 0 100 100" preserveAspectRatio="none">
{['M 23 45 C 30 22, 35 22, 41 45', 'M 59 45 C 66 22, 71 22, 77 45'].map((d) => (
<path key={d} d={d} fill="none" stroke={selectionBlue} strokeWidth={2} strokeDasharray="6 6" vectorEffect="non-scaling-stroke" style={{ animation: 'lattice-dash 1.2s linear infinite' }} />
))}
</svg>
<div className="relative grid gap-8 md:grid-cols-3 md:gap-16">
{screens.map((s, i) => (
<div key={s.title} className="flex flex-col items-center">
<span className={cn('mb-3 font-mono text-[11px] transition-colors', step === i ? 'text-[#0d99ff]' : 'text-zinc-400')}>0{i + 1} / {s.title}</span>
<div className={cn('relative w-full max-w-[220px] rounded-[28px] border-[6px] border-zinc-900 bg-gradient-to-b p-4 transition-all duration-500 ease-spring', s.tint, step === i ? 'scale-105 shadow-[0_30px_60px_-20px_rgba(13,153,255,0.5)]' : 'shadow-xl', step !== -1 && step !== i && 'opacity-50')}>
<div className="mx-auto h-1.5 w-10 rounded-full bg-zinc-300" />
<div className={cn('mx-auto mt-8 grid h-14 w-14 place-items-center rounded-2xl text-white', s.accent)}><Smartphone className="h-6 w-6" /></div>
<div className="mx-auto mt-5 h-3 w-3/4 rounded bg-zinc-900" />
<div className="mx-auto mt-2 h-2 w-2/3 rounded bg-zinc-300" />
<div className="mx-auto mt-1.5 h-2 w-1/2 rounded bg-zinc-300" />
<div className={cn('mt-10 h-9 rounded-xl', s.accent, step === i && 'animate-pulse')} />
</div>
{i < screens.length - 1 && (
<span className="mt-4 rounded-md bg-white px-2 py-1 font-mono text-[10px] text-zinc-500 ring-1 ring-zinc-200">On tap → Smart animate · 300ms</span>
)}
</div>
))}
</div>
</Reveal>
</div>
</section>
);
}
const code: Record<string, { lang: string; src: string }> = {
React: { lang: 'tsx', src: `export function PrimaryButton({ children }) {\n return (\n <button className="btn btn-primary">\n <Sparkles size={16} />\n {children}\n </button>\n );\n}` },
Tailwind: { lang: 'html', src: `<button class="inline-flex h-11 items-center gap-2\n rounded-xl bg-zinc-950 px-5 text-sm\n font-medium text-white shadow-sm">\n Generate\n</button>` },
CSS: { lang: 'css', src: `.btn-primary {\n display: inline-flex;\n gap: 8px;\n height: 44px;\n padding: 0 20px;\n border-radius: 12px;\n background: var(--zinc-950);\n font: 500 14px/1 'Geist';\n}` },
};
export function DevMode() {
const [tab, setTab] = useState('React');
return (
<section id="lattice-dev" className="scroll-mt-16 bg-zinc-50 py-28">
<div className="mx-auto max-w-6xl px-6">
<SectionHead tag="Dev Mode" color="#f43f5e" title={<>From pixels to <span className="text-zinc-400">production code.</span></>} body="Engineers get specs, measurements and code that actually match your design system. No more redline PDFs." />
<Reveal className="mt-14 grid overflow-hidden rounded-3xl border border-zinc-200 bg-white lg:grid-cols-2">
<div className="relative grid min-h-[320px] place-items-center p-10" style={{ backgroundImage: 'radial-gradient(#e4e4e7 1px, transparent 1px)', backgroundSize: '16px 16px' }}>
<div className="relative">
<span className="absolute -top-8 left-0 right-0 flex items-center justify-center">
<span className="absolute inset-x-0 top-1/2 h-px bg-rose-500" />
<span className="relative rounded bg-rose-500 px-1.5 font-mono text-[10px] text-white">148</span>
</span>
<span className="absolute -left-10 bottom-0 top-0 flex items-center">
<span className="absolute inset-y-0 left-1/2 w-px bg-rose-500" />
<span className="relative rounded bg-rose-500 px-1 font-mono text-[10px] text-white">44</span>
</span>
<button className="inline-flex h-11 items-center gap-2 rounded-xl bg-zinc-950 px-5 text-sm font-medium text-white outline outline-[1.5px] outline-offset-4 outline-[#0d99ff]">
<Sparkles className="h-4 w-4" /> Generate
</button>
</div>
</div>
<div className="border-t border-zinc-200 bg-zinc-950 p-4 lg:border-l lg:border-t-0">
<div className="mb-3 flex gap-1">
{Object.keys(code).map((k) => (
<button key={k} onClick={() => setTab(k)} className={cn('rounded-lg px-3 py-1.5 text-xs font-medium transition-colors', tab === k ? 'bg-white/10 text-white' : 'text-zinc-500 hover:text-zinc-200')}>{k}</button>
))}
</div>
<div key={tab} className="animate-fade-in">
<CodeBlock code={code[tab].src} language={code[tab].lang} />
</div>
</div>
</Reveal>
</div>
</section>
);
}components/niceui/ui/CodeBlock.tsx
import { useState } from 'react';
import { Check, Copy } from 'lucide-react';
import { cn } from '@/lib/cn';
const KEYWORDS =
/\b(import|from|export|default|const|let|var|function|return|if|else|for|while|async|await|new|type|interface|extends|true|false|null|undefined|class|def|print)\b/;
type Token = { text: string; cls: string };
function tokenize(line: string): Token[] {
const tokens: Token[] = [];
const re =
/(\/\/.*$|#.*$)|("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)|(<\/?[A-Za-z][\w.]*)|(\b\d+(?:\.\d+)?\b)|(\b[A-Za-z_$][\w$]*\b)|(\s+)|([^\sA-Za-z0-9_$"'`]+)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(line))) {
const [text, comment, str, tag, num, word] = m;
let cls = 'text-ink-300';
if (comment) cls = 'text-ink-500 italic';
else if (str) cls = 'text-emerald-300';
else if (tag) cls = 'text-brand-300';
else if (num) cls = 'text-amber-300';
else if (word && KEYWORDS.test(word)) cls = 'text-rose-300';
else if (word && /^[A-Z]/.test(word)) cls = 'text-sky-200';
tokens.push({ text, cls });
}
return tokens;
}
type CodeBlockProps = {
code: string;
filename?: string;
language?: string;
showLineNumbers?: boolean;
highlightLines?: number[];
className?: string;
maxHeight?: number;
};
export function CodeBlock({
code,
filename,
language = 'tsx',
showLineNumbers = true,
highlightLines = [],
className,
maxHeight = 480,
}: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const lines = code.replace(/\n$/, '').split('\n');
const copy = async () => {
try {
await navigator.clipboard.writeText(code);
setCopied(true);
setTimeout(() => setCopied(false), 1600);
} catch {
setCopied(false);
}
};
return (
<div className={cn('group relative overflow-hidden rounded-xl bg-ink-950 shadow-hairline', className)}>
<div className="flex items-center justify-between border-b border-white/5 px-4 py-2.5">
<div className="flex items-center gap-2 text-xs text-ink-400">
<span className="flex gap-1.5">
<span className="h-2.5 w-2.5 rounded-full bg-ink-700" />
<span className="h-2.5 w-2.5 rounded-full bg-ink-700" />
<span className="h-2.5 w-2.5 rounded-full bg-ink-700" />
</span>
<span className="ml-2 font-mono">{filename ?? language}</span>
</div>
<button
onClick={copy}
className="focus-ring relative flex h-7 items-center gap-1.5 rounded-md px-2 text-xs text-ink-400 transition-colors hover:bg-white/5 hover:text-ink-100"
aria-label="Copy code"
>
<span className="relative h-3.5 w-3.5">
<Copy className={cn('absolute inset-0 h-3.5 w-3.5 transition-all duration-300', copied ? 'scale-50 opacity-0' : 'scale-100 opacity-100')} />
<Check className={cn('absolute inset-0 h-3.5 w-3.5 text-emerald-400 transition-all duration-300 ease-spring', copied ? 'scale-100 opacity-100' : 'scale-50 opacity-0')} />
</span>
{copied ? 'Copied' : 'Copy'}
</button>
</div>
<pre className="overflow-auto py-3 font-mono text-[13px] leading-6" style={{ maxHeight }}>
<code>
{lines.map((line, i) => (
<div
key={i}
className={cn(
'flex px-4',
highlightLines.includes(i + 1) && 'bg-brand-500/10 shadow-[inset_2px_0_0_theme(colors.brand.400)]',
)}
>
{showLineNumbers && <span className="mr-5 w-6 shrink-0 select-none text-right text-ink-600">{i + 1}</span>}
<span className="whitespace-pre">
{tokenize(line).map((t, j) => (
<span key={j} className={t.cls}>
{t.text}
</span>
))}
</span>
</div>
))}
</code>
</pre>
</div>
);
}components/niceui/pages/landing/lattice/Hero.tsx
import { useEffect, useState } from 'react';
import { ArrowRight, Command, Menu, MousePointer2, Shapes, X } from 'lucide-react';
import { cn } from '@/lib/cn';
import { Editor } from './Editor';
import { LButton, SelectionBox, scrollToSection } from './ui';
const nav = [
{ label: 'Product', id: 'ai' },
{ label: 'Multiplayer', id: 'multiplayer' },
{ label: 'Prototyping', id: 'prototype' },
{ label: 'Dev Mode', id: 'dev' },
{ label: 'Pricing', id: 'pricing' },
];
export function LatticeNav() {
const [scrolled, setScrolled] = useState(false);
const [open, setOpen] = useState(false);
useEffect(() => {
const main = document.querySelector('main');
const onScroll = () => setScrolled((main?.scrollTop ?? 0) > 16 || window.scrollY > 16);
main?.addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('scroll', onScroll, { passive: true });
return () => {
main?.removeEventListener('scroll', onScroll);
window.removeEventListener('scroll', onScroll);
};
}, []);
const go = (id: string) => {
setOpen(false);
scrollToSection(id);
};
return (
<header className={cn('sticky top-0 z-50 transition-all duration-300', scrolled ? 'border-b border-zinc-200/80 bg-white/80 backdrop-blur-xl' : 'border-b border-transparent')}>
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-6">
<button onClick={() => go('top')} className="flex items-center gap-2.5">
<span className="relative grid h-8 w-8 place-items-center rounded-lg bg-zinc-950 text-white">
<Shapes className="h-4 w-4" />
<span className="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full border-2 border-white bg-[#0d99ff]" />
</span>
<span className="text-[17px] font-semibold tracking-tight text-zinc-950">Lattice</span>
</button>
<nav className="hidden items-center gap-1 md:flex">
{nav.map((n) => (
<button key={n.id} onClick={() => go(n.id)} className="rounded-lg px-3.5 py-2 text-sm text-zinc-600 transition-colors hover:bg-zinc-100 hover:text-zinc-950">{n.label}</button>
))}
</nav>
<div className="flex items-center gap-2">
<LButton variant="ghost" className="hidden h-9 sm:inline-flex" onClick={() => go('cta')}>Log in</LButton>
<LButton className="h-9 px-4" onClick={() => go('cta')}>Get started</LButton>
<button onClick={() => setOpen((o) => !o)} aria-label="Toggle menu" className="grid h-9 w-9 place-items-center rounded-lg text-zinc-900 hover:bg-zinc-100 md:hidden">
{open ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
</div>
</div>
<div className={cn('grid border-zinc-200 bg-white transition-all duration-300 md:hidden', open ? 'grid-rows-[1fr] border-t' : 'grid-rows-[0fr]')}>
<div className="overflow-hidden">
<div className="flex flex-col p-3">
{nav.map((n) => (
<button key={n.id} onClick={() => go(n.id)} className="rounded-lg px-3 py-3 text-left text-base font-medium text-zinc-900 hover:bg-zinc-100">{n.label}</button>
))}
</div>
</div>
</div>
</header>
);
}
const logos = ['Northwind', 'Lumon', 'Hooli', 'Pied Piper', 'Initech', 'Vandelay', 'Soylent', 'Wonka Labs'];
export function LatticeHero() {
return (
<section id="lattice-top" className="relative overflow-hidden pb-20 pt-16 md:pt-24">
<div className="pointer-events-none absolute inset-0 [mask-image:radial-gradient(ellipse_at_top,black_30%,transparent_75%)]" style={{ backgroundImage: 'linear-gradient(#e4e4e7 1px, transparent 1px), linear-gradient(90deg, #e4e4e7 1px, transparent 1px)', backgroundSize: '56px 56px' }} />
<div className="relative mx-auto max-w-7xl px-6">
<div className="mx-auto max-w-4xl text-center">
<button onClick={() => scrollToSection('ai')} className="group inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white py-1 pl-1 pr-3 text-sm text-zinc-700 shadow-sm transition-colors hover:border-zinc-300 animate-fade-down">
<span className="rounded-full bg-zinc-950 px-2 py-0.5 text-xs font-medium text-white">Lattice AI 2.0</span>
Generate full flows from a sentence
<ArrowRight className="h-3.5 w-3.5 text-zinc-400 transition-transform group-hover:translate-x-0.5" />
</button>
<h1 className="mt-10 text-5xl font-semibold leading-[1.02] tracking-[-0.045em] text-zinc-950 sm:text-7xl lg:text-[88px]">
<span className="inline-block animate-fade-up">Design at the</span>{' '}
<span className="inline-block animate-fade-up" style={{ animationDelay: '120ms', animationFillMode: 'both' }}>
<SelectionBox label="speed.tsx">speed</SelectionBox>
</span>{' '}
<span className="inline-block animate-fade-up" style={{ animationDelay: '240ms', animationFillMode: 'both' }}>of thought.</span>
</h1>
<p className="mx-auto mt-8 max-w-2xl text-lg leading-relaxed text-zinc-600 animate-fade-up md:text-xl" style={{ animationDelay: '360ms', animationFillMode: 'both' }}>
Lattice is the multiplayer canvas where designers, engineers and AI build products together. Sketch, generate, prototype and ship, without ever leaving the file.
</p>
<div className="mt-10 flex flex-wrap items-center justify-center gap-3 animate-fade-up" style={{ animationDelay: '480ms', animationFillMode: 'both' }}>
<LButton className="h-12 px-6" onClick={() => scrollToSection('cta')}>Start designing free <ArrowRight className="h-4 w-4" /></LButton>
<LButton variant="light" className="h-12 px-6" onClick={() => scrollToSection('ai')}>
<Command className="h-4 w-4" /> See what AI can do
</LButton>
</div>
<p className="mt-5 text-sm text-zinc-500 animate-fade-in" style={{ animationDelay: '600ms', animationFillMode: 'both' }}>Free forever for individuals. No credit card.</p>
</div>
<div className="relative mx-auto mt-16 max-w-6xl animate-fade-up" style={{ animationDelay: '500ms', animationFillMode: 'both' }}>
<div className="pointer-events-none absolute -inset-x-10 -top-10 bottom-10 -z-10 rounded-[40px] bg-gradient-to-b from-[#0d99ff]/15 via-orange-200/20 to-transparent blur-3xl" />
<div className="absolute -left-3 -top-8 z-10 hidden items-center gap-1.5 rounded-full bg-white px-3 py-1.5 text-xs text-zinc-700 shadow-md ring-1 ring-zinc-200 md:flex animate-float">
<MousePointer2 className="h-3.5 w-3.5 fill-[#0d99ff] text-[#0d99ff]" /> Try it: drag, resize, press R, or hit Generate
</div>
<Editor />
</div>
<div className="mt-20">
<p className="text-center text-sm text-zinc-500">Trusted by product teams at 40,000+ companies</p>
<div className="relative mt-8 flex overflow-hidden mask-fade-x">
<div className="flex shrink-0 animate-marquee items-center gap-14 pr-14 [animation-duration:36s]">
{[...logos, ...logos].map((l, i) => (
<span key={i} className="whitespace-nowrap text-xl font-semibold tracking-tight text-zinc-300 transition-colors hover:text-zinc-900">{l}</span>
))}
</div>
</div>
</div>
</div>
</section>
);
}components/niceui/pages/landing/lattice/Editor.tsx
import { useEffect, useRef, useState, type FormEvent, type KeyboardEvent, type PointerEvent } from 'react';
import { Circle, Frame, Loader2, MousePointer2, Play, Sparkles, Square, Type } from 'lucide-react';
import { cn } from '@/lib/cn';
import { generatedCard, initialShapes, makeShape, type Shape, type Tool } from './editorModel';
import { Inspector, LayersPanel } from './EditorPanels';
const BOARD_W = 820;
const tools: { id: Tool; icon: typeof Frame; key: string; label: string }[] = [
{ id: 'select', icon: MousePointer2, key: 'V', label: 'Move' },
{ id: 'frame', icon: Frame, key: 'F', label: 'Frame' },
{ id: 'rect', icon: Square, key: 'R', label: 'Rectangle' },
{ id: 'ellipse', icon: Circle, key: 'O', label: 'Ellipse' },
{ id: 'text', icon: Type, key: 'T', label: 'Text' },
];
const peers = [
{ name: 'Mira', color: '#f97316', path: [[620, 110], [480, 260], [700, 360], [560, 200]] },
{ name: 'Theo', color: '#10b981', path: [[200, 380], [330, 160], [150, 250], [300, 420]] },
];
function PeerCursor({ name, color, path, scale }: { name: string; color: string; path: number[][]; scale: number }) {
const [i, setI] = useState(0);
useEffect(() => {
const id = setInterval(() => setI((n) => (n + 1) % path.length), 2400);
return () => clearInterval(id);
}, [path.length]);
const [x, y] = path[i];
return (
<div className="pointer-events-none absolute left-0 top-0 z-30 transition-transform duration-[2200ms] ease-smooth" style={{ transform: `translate(${x * scale}px, ${y * scale}px)` }}>
<MousePointer2 className="h-5 w-5 -rotate-12" style={{ color, fill: color }} />
<span className="ml-4 inline-block rounded-full px-2 py-0.5 text-[11px] font-medium text-white shadow-sm" style={{ background: color }}>{name}</span>
</div>
);
}
type Drag = { id: string; mode: 'move' | 'resize'; sx: number; sy: number; ox: number; oy: number; ow: number; oh: number };
export function Editor() {
const [shapes, setShapes] = useState<Shape[]>(initialShapes);
const [selected, setSelected] = useState<string | null>('r1');
const [tool, setTool] = useState<Tool>('select');
const [prompt, setPrompt] = useState('Pricing card, dark, with CTA');
const [generating, setGenerating] = useState(false);
const [scale, setScale] = useState(1);
const boardRef = useRef<HTMLDivElement>(null);
const drag = useRef<Drag | null>(null);
const timers = useRef<number[]>([]);
useEffect(() => {
const el = boardRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => setScale(Math.min(1, entry.contentRect.width / BOARD_W)));
ro.observe(el);
return () => ro.disconnect();
}, []);
useEffect(() => () => timers.current.forEach(clearTimeout), []);
const update = (id: string, patch: Partial<Shape>) => setShapes((list) => list.map((s) => (s.id === id ? { ...s, ...patch } : s)));
const remove = (id: string) => {
setShapes((list) => list.filter((s) => s.id !== id));
setSelected(null);
};
const local = (e: PointerEvent) => {
const b = boardRef.current!.getBoundingClientRect();
return { x: (e.clientX - b.left) / scale, y: (e.clientY - b.top) / scale };
};
const onBoardDown = (e: PointerEvent<HTMLDivElement>) => {
if (tool === 'select') {
setSelected(null);
return;
}
const p = local(e);
const shape = makeShape(tool, p.x, p.y, shapes.length + 1);
setShapes((list) => [...list, shape]);
setSelected(shape.id);
setTool('select');
};
const startDrag = (e: PointerEvent<HTMLElement>, s: Shape, mode: Drag['mode']) => {
if (tool !== 'select') return;
e.stopPropagation();
setSelected(s.id);
const p = local(e);
drag.current = { id: s.id, mode, sx: p.x, sy: p.y, ox: s.x, oy: s.y, ow: s.w, oh: s.h };
e.currentTarget.setPointerCapture(e.pointerId);
};
const onMove = (e: PointerEvent<HTMLDivElement>) => {
const d = drag.current;
if (!d) return;
const p = local(e);
const dx = p.x - d.sx;
const dy = p.y - d.sy;
if (d.mode === 'move') update(d.id, { x: Math.round(d.ox + dx), y: Math.round(d.oy + dy) });
else update(d.id, { w: Math.max(12, Math.round(d.ow + dx)), h: Math.max(2, Math.round(d.oh + dy)) });
};
const generate = (e: FormEvent) => {
e.preventDefault();
if (generating || !prompt.trim()) return;
setGenerating(true);
const offset = (shapes.filter((s) => s.name === 'AI / Pricing card').length % 3) * 18;
const parts = generatedCard(580 + offset, 40 + offset);
timers.current.push(
window.setTimeout(() => {
parts.forEach((part, i) => {
timers.current.push(window.setTimeout(() => setShapes((list) => [...list, part]), i * 130));
});
timers.current.push(
window.setTimeout(() => {
setSelected(parts[0].id);
setGenerating(false);
}, parts.length * 130),
);
}, 1100),
);
};
const onKey = (e: KeyboardEvent<HTMLDivElement>) => {
if ((e.target as HTMLElement).tagName === 'INPUT') return;
const t = tools.find((x) => x.key.toLowerCase() === e.key.toLowerCase());
if (t) return setTool(t.id);
if (!selected) return;
const s = shapes.find((x) => x.id === selected);
if (!s) return;
const step = e.shiftKey ? 10 : 1;
const nudges: Record<string, Partial<Shape>> = {
ArrowLeft: { x: s.x - step }, ArrowRight: { x: s.x + step }, ArrowUp: { y: s.y - step }, ArrowDown: { y: s.y + step },
};
if (nudges[e.key]) {
e.preventDefault();
update(s.id, nudges[e.key]);
} else if (e.key === 'Delete' || e.key === 'Backspace') remove(s.id);
else if (e.key === 'Escape') setSelected(null);
};
const current = shapes.find((s) => s.id === selected) ?? null;
return (
<div tabIndex={0} onKeyDown={onKey} className="flex h-[560px] flex-col overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-[0_50px_100px_-40px_rgba(24,24,27,0.35)] outline-none focus-visible:ring-2 focus-visible:ring-[#0d99ff]/40">
<div className="flex h-12 shrink-0 items-center justify-between border-b border-zinc-200 bg-zinc-950 px-3 text-white">
<div className="flex items-center gap-1">
{tools.map((t) => (
<button
key={t.id}
onClick={() => setTool(t.id)}
title={`${t.label} (${t.key})`}
aria-label={t.label}
className={cn('grid h-8 w-8 place-items-center rounded-md transition-colors', tool === t.id ? 'bg-[#0d99ff] text-white' : 'text-zinc-400 hover:bg-white/10 hover:text-white')}
>
<t.icon className="h-4 w-4" />
</button>
))}
</div>
<span className="hidden text-xs text-zinc-400 sm:block">Marketing site <span className="text-zinc-600">/</span> <span className="text-white">Hero v3</span></span>
<div className="flex items-center gap-2">
<div className="flex -space-x-1.5">
{[['M', '#f97316'], ['T', '#10b981'], ['Y', '#0d99ff']].map(([l, c]) => (
<span key={l} className="grid h-7 w-7 place-items-center rounded-full text-[11px] font-semibold ring-2 ring-zinc-950" style={{ background: c }}>{l}</span>
))}
</div>
<button aria-label="Present" className="grid h-8 w-8 place-items-center rounded-md text-zinc-400 hover:bg-white/10 hover:text-white"><Play className="h-4 w-4" /></button>
<button className="h-8 rounded-md bg-[#0d99ff] px-3 text-xs font-medium text-white transition-colors hover:bg-[#0b87e0]">Share</button>
</div>
</div>
<div className="flex min-h-0 flex-1">
<LayersPanel shapes={shapes} selected={selected} onSelect={setSelected} />
<div className="relative min-w-0 flex-1 overflow-hidden bg-[#f5f5f5]" style={{ backgroundImage: 'radial-gradient(#d4d4d8 1px, transparent 1px)', backgroundSize: '16px 16px' }}>
<div
ref={boardRef}
onPointerDown={onBoardDown}
onPointerMove={onMove}
onPointerUp={() => (drag.current = null)}
className={cn('absolute inset-0 touch-none', tool === 'select' ? 'cursor-default' : 'cursor-crosshair')}
>
{shapes.map((s) => {
const isSel = s.id === selected;
return (
<div
key={s.id}
onPointerDown={(e) => startDrag(e, s, 'move')}
className={cn('group absolute', s.fresh && 'animate-scale-in', tool === 'select' && 'cursor-move')}
style={{ left: s.x * scale, top: s.y * scale, width: s.w * scale, height: s.h * scale }}
>
{s.kind === 'text' ? (
<div className="h-full w-full select-none whitespace-nowrap font-semibold leading-none tracking-tight" style={{ color: s.fill, fontSize: s.h * 0.78 * scale }}>{s.text}</div>
) : (
<div
className={cn('h-full w-full', s.kind === 'frame' && 'shadow-[0_1px_3px_rgba(0,0,0,0.08),0_10px_30px_-12px_rgba(0,0,0,0.15)]')}
style={{ background: s.fill, borderRadius: s.kind === 'ellipse' ? '9999px' : s.radius * scale }}
/>
)}
{s.kind === 'frame' && <span className="absolute -top-5 left-0 whitespace-nowrap text-[10px] text-zinc-500">{s.name}</span>}
<div className={cn('pointer-events-none absolute -inset-px border-[1.5px] border-[#0d99ff] transition-opacity', isSel ? 'opacity-100' : 'opacity-0 group-hover:opacity-60')} />
{isSel && (
<>
{['-left-1 -top-1', '-right-1 -top-1', '-left-1 -bottom-1'].map((p) => (
<span key={p} className={cn('pointer-events-none absolute h-2 w-2 border-[1.5px] border-[#0d99ff] bg-white', p)} />
))}
<span onPointerDown={(e) => startDrag(e, s, 'resize')} className="absolute -bottom-1.5 -right-1.5 h-3 w-3 cursor-nwse-resize border-[1.5px] border-[#0d99ff] bg-white" />
<span className="pointer-events-none absolute left-1/2 top-full mt-2 -translate-x-1/2 whitespace-nowrap rounded bg-[#0d99ff] px-1.5 py-0.5 font-mono text-[10px] text-white">
{Math.round(s.w)} × {Math.round(s.h)}
</span>
</>
)}
</div>
);
})}
{peers.map((p) => <PeerCursor key={p.name} {...p} scale={scale} />)}
{generating && (
<div className="absolute overflow-hidden rounded-2xl border-2 border-dashed border-[#0d99ff]/60 bg-[#0d99ff]/5" style={{ left: 580 * scale, top: 40 * scale, width: 210 * scale, height: 230 * scale }}>
<div className="absolute inset-0 -translate-x-full animate-shimmer bg-gradient-to-r from-transparent via-[#0d99ff]/20 to-transparent" />
</div>
)}
</div>
<form onSubmit={generate} onPointerDown={(e) => e.stopPropagation()} className="absolute bottom-4 left-1/2 z-40 flex w-[min(92%,440px)] -translate-x-1/2 items-center gap-2 rounded-xl border border-zinc-200 bg-white p-1.5 pl-3 shadow-[0_12px_40px_-12px_rgba(0,0,0,0.25)]">
<Sparkles className={cn('h-4 w-4 shrink-0 text-[#0d99ff]', generating && 'animate-spin-slow')} />
<input
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
aria-label="Describe what to generate"
placeholder="Describe a component..."
className="min-w-0 flex-1 bg-transparent text-sm text-zinc-900 outline-none placeholder:text-zinc-400"
/>
<button disabled={generating} className="flex h-8 items-center gap-1.5 rounded-lg bg-zinc-950 px-3 text-xs font-medium text-white transition-colors hover:bg-zinc-800 disabled:opacity-70">
{generating ? <><Loader2 className="h-3.5 w-3.5 animate-spin" /> Generating</> : 'Generate'}
</button>
</form>
<span className="pointer-events-none absolute left-3 top-3 rounded-md bg-white/80 px-2 py-1 font-mono text-[10px] text-zinc-500 ring-1 ring-zinc-200 backdrop-blur">{Math.round(scale * 100)}%</span>
</div>
<Inspector shape={current} onChange={(patch) => current && update(current.id, patch)} onDelete={() => current && remove(current.id)} />
</div>
</div>
);
}components/niceui/pages/landing/lattice/editorModel.ts
export type ShapeKind = 'frame' | 'rect' | 'ellipse' | 'text';
export type Tool = 'select' | 'frame' | 'rect' | 'ellipse' | 'text';
export interface Shape {
id: string;
kind: ShapeKind;
name: string;
x: number;
y: number;
w: number;
h: number;
fill: string;
radius: number;
text?: string;
fresh?: boolean;
}
export const swatches = ['#ffffff', '#18181b', '#0d99ff', '#f97316', '#10b981', '#f43f5e', '#facc15', '#e4e4e7'];
export const initialShapes: Shape[] = [
{ id: 'f1', kind: 'frame', name: 'Hero / Desktop', x: 40, y: 36, w: 340, h: 250, fill: '#ffffff', radius: 14 },
{ id: 't1', kind: 'text', name: 'Headline', x: 64, y: 70, w: 230, h: 30, fill: '#18181b', radius: 0, text: 'Ship it faster' },
{ id: 'r1', kind: 'rect', name: 'Image', x: 64, y: 116, w: 292, h: 100, fill: '#0d99ff', radius: 10 },
{ id: 'r2', kind: 'rect', name: 'Button', x: 64, y: 232, w: 112, h: 34, fill: '#18181b', radius: 17 },
{ id: 'e1', kind: 'ellipse', name: 'Avatar', x: 420, y: 70, w: 84, h: 84, fill: '#f97316', radius: 0 },
{ id: 'r3', kind: 'rect', name: 'Sticky note', x: 410, y: 182, w: 130, h: 104, fill: '#facc15', radius: 6 },
];
const defaults: Record<ShapeKind, Pick<Shape, 'w' | 'h' | 'fill' | 'radius'> & { text?: string }> = {
frame: { w: 200, h: 140, fill: '#ffffff', radius: 12 },
rect: { w: 120, h: 80, fill: '#10b981', radius: 8 },
ellipse: { w: 80, h: 80, fill: '#f43f5e', radius: 0 },
text: { w: 160, h: 28, fill: '#18181b', radius: 0, text: 'Type something' },
};
export function makeShape(kind: ShapeKind, x: number, y: number, n: number): Shape {
const d = defaults[kind];
const label = kind[0].toUpperCase() + kind.slice(1);
return { id: crypto.randomUUID(), kind, name: `${label} ${n}`, x: x - d.w / 2, y: y - d.h / 2, ...d, fresh: true };
}
export function generatedCard(ox: number, oy: number): Shape[] {
const id = crypto.randomUUID();
return [
{ id: `g-${id}-0`, kind: 'frame', name: 'AI / Pricing card', x: ox, y: oy, w: 210, h: 230, fill: '#18181b', radius: 16, fresh: true },
{ id: `g-${id}-1`, kind: 'text', name: 'Plan', x: ox + 20, y: oy + 22, w: 120, h: 18, fill: '#a1a1aa', radius: 0, text: 'PRO PLAN', fresh: true },
{ id: `g-${id}-2`, kind: 'text', name: 'Price', x: ox + 20, y: oy + 48, w: 150, h: 40, fill: '#ffffff', radius: 0, text: '$24/mo', fresh: true },
{ id: `g-${id}-3`, kind: 'rect', name: 'Divider', x: ox + 20, y: oy + 104, w: 170, h: 2, fill: '#3f3f46', radius: 1, fresh: true },
{ id: `g-${id}-4`, kind: 'rect', name: 'Feature', x: ox + 20, y: oy + 124, w: 140, h: 10, fill: '#52525b', radius: 5, fresh: true },
{ id: `g-${id}-5`, kind: 'rect', name: 'Feature', x: ox + 20, y: oy + 144, w: 110, h: 10, fill: '#52525b', radius: 5, fresh: true },
{ id: `g-${id}-6`, kind: 'rect', name: 'CTA', x: ox + 20, y: oy + 178, w: 170, h: 34, fill: '#0d99ff', radius: 10, fresh: true },
];
}components/niceui/pages/landing/lattice/EditorPanels.tsx
import { Circle, Frame, Square, Trash2, Type } from 'lucide-react';
import { cn } from '@/lib/cn';
import { swatches, type Shape, type ShapeKind } from './editorModel';
export const kindIcon: Record<ShapeKind, typeof Frame> = { frame: Frame, rect: Square, ellipse: Circle, text: Type };
export function LayersPanel({ shapes, selected, onSelect }: { shapes: Shape[]; selected: string | null; onSelect: (id: string) => void }) {
return (
<aside className="hidden w-52 shrink-0 flex-col border-r border-zinc-200 bg-white lg:flex">
<div className="flex items-center justify-between border-b border-zinc-200 px-4 py-3">
<span className="text-xs font-semibold text-zinc-900">Layers</span>
<span className="font-mono text-[10px] text-zinc-400">{shapes.length}</span>
</div>
<div className="flex-1 overflow-y-auto p-1.5">
{[...shapes].reverse().map((s) => {
const Icon = kindIcon[s.kind];
const active = s.id === selected;
return (
<button
key={s.id}
onClick={() => onSelect(s.id)}
className={cn(
'flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-xs transition-colors',
active ? 'bg-[#0d99ff]/10 text-[#0b7fd4]' : 'text-zinc-700 hover:bg-zinc-100',
s.fresh && 'animate-slide-in-right',
)}
>
<Icon className={cn('h-3.5 w-3.5 shrink-0', active ? 'text-[#0d99ff]' : 'text-zinc-400')} />
<span className="truncate">{s.name}</span>
</button>
);
})}
</div>
</aside>
);
}
function NumField({ label, value, onChange }: { label: string; value: number; onChange: (v: number) => void }) {
return (
<label className="flex items-center gap-2 rounded-md bg-zinc-100 px-2 py-1.5 transition-colors focus-within:bg-white focus-within:ring-1 focus-within:ring-[#0d99ff]">
<span className="w-3 text-[10px] font-medium text-zinc-400">{label}</span>
<input
type="number"
value={Math.round(value)}
onChange={(e) => {
const n = Number(e.target.value);
if (Number.isFinite(n)) onChange(n);
}}
className="w-full min-w-0 bg-transparent font-mono text-[11px] text-zinc-900 outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none"
/>
</label>
);
}
export function Inspector({ shape, onChange, onDelete }: { shape: Shape | null; onChange: (patch: Partial<Shape>) => void; onDelete: () => void }) {
return (
<aside className="hidden w-60 shrink-0 flex-col border-l border-zinc-200 bg-white xl:flex">
<div className="flex items-center gap-4 border-b border-zinc-200 px-4 py-3 text-xs">
<span className="font-semibold text-zinc-900">Design</span>
<span className="text-zinc-400">Prototype</span>
<span className="text-zinc-400">Inspect</span>
</div>
{shape ? (
<div key={shape.id} className="flex-1 space-y-5 overflow-y-auto p-4 animate-fade-in">
<div>
<div className="flex items-center justify-between">
<span className="truncate text-xs font-semibold text-zinc-900">{shape.name}</span>
<button onClick={onDelete} aria-label="Delete layer" className="rounded p-1 text-zinc-400 transition-colors hover:bg-rose-50 hover:text-rose-600">
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
<div className="mt-3 grid grid-cols-2 gap-1.5">
<NumField label="X" value={shape.x} onChange={(x) => onChange({ x })} />
<NumField label="Y" value={shape.y} onChange={(y) => onChange({ y })} />
<NumField label="W" value={shape.w} onChange={(w) => onChange({ w: Math.max(8, w) })} />
<NumField label="H" value={shape.h} onChange={(h) => onChange({ h: Math.max(2, h) })} />
</div>
</div>
{shape.kind !== 'ellipse' && shape.kind !== 'text' && (
<div>
<div className="flex justify-between text-[11px] text-zinc-500">
<span>Corner radius</span>
<span className="font-mono text-zinc-900">{shape.radius}</span>
</div>
<input
type="range" min={0} max={40} value={shape.radius}
onChange={(e) => onChange({ radius: Number(e.target.value) })}
className="mt-2 w-full accent-[#0d99ff]"
/>
</div>
)}
{shape.kind === 'text' && (
<div>
<span className="text-[11px] text-zinc-500">Content</span>
<input
value={shape.text ?? ''}
onChange={(e) => onChange({ text: e.target.value })}
className="mt-2 w-full rounded-md bg-zinc-100 px-2 py-1.5 text-xs text-zinc-900 outline-none focus:bg-white focus:ring-1 focus:ring-[#0d99ff]"
/>
</div>
)}
<div>
<span className="text-[11px] text-zinc-500">Fill</span>
<div className="mt-2 grid grid-cols-8 gap-1.5">
{swatches.map((c) => (
<button
key={c}
onClick={() => onChange({ fill: c })}
aria-label={`Fill ${c}`}
className={cn('aspect-square rounded-md ring-1 ring-zinc-200 transition-transform hover:scale-110', shape.fill === c && 'ring-2 ring-[#0d99ff] ring-offset-1')}
style={{ background: c }}
/>
))}
</div>
<div className="mt-2 flex items-center gap-2 rounded-md bg-zinc-100 px-2 py-1.5 font-mono text-[11px] text-zinc-700">
<span className="h-3.5 w-3.5 rounded-sm ring-1 ring-zinc-300" style={{ background: shape.fill }} />
{shape.fill.toUpperCase()}
<span className="ml-auto text-zinc-400">100%</span>
</div>
</div>
</div>
) : (
<div className="flex flex-1 flex-col items-center justify-center p-6 text-center">
<Square className="h-6 w-6 text-zinc-300" />
<p className="mt-3 text-xs leading-relaxed text-zinc-500">Select a layer to edit its size, radius and fill.</p>
</div>
)}
</aside>
);
}