A split hero for a sync engine with a live demo beside the headline: a laptop and a phone on one checklist. A handwritten note asks the visitor to tick something off on the phone, then swings its arrow to the laptop, where the same row just ticked itself and flashed. Another note points at the one-line install command.
registry/blocks/jot-hero.tsx
"use client";
import * as React from "react";
import { motion } from "motion/react";
import { Jot } from "@/components/ui/jot";
/* ── Content ─────────────────────────────────────────────────
* Everything you'd change to make the hero yours: the nav, the
* copy, the install command, the list both devices share and
* the handwritten notes. The note over the devices asks the
* visitor to tick something off on the phone, then swings its
* arrow to the laptop, where the same row just ticked itself.
* ─────────────────────────────────────────────────────────── */
/** The one accent: the primary button, the checkmarks and the sync flash. */
const ACCENT = { light: "#0f766e", dark: "#2dd4bf" };
const BRAND = { name: "Shoal", href: "#" };
const NAV_LINKS = [
{ label: "Docs", href: "#docs" },
{ label: "Customers", href: "#customers" },
{ label: "Pricing", href: "#pricing" },
{ label: "Changelog", href: "#changelog" },
];
const SIGN_IN = { label: "Sign in", href: "#sign-in" };
const ANNOUNCEMENT = { tag: "New", text: "Offline mode for React Native", href: "#changelog" };
const HEADLINE = "Your app, in sync";
const HEADLINE_QUIET = "on every device.";
const SUBLINE =
"Shoal is the sync engine for apps people keep open all day. Writes land offline, merge without conflicts and reach every other device in about 40 ms.";
const PRIMARY_CTA = { label: "Start building", href: "#start" };
const INSTALL = "npm i @shoal/react";
const FOOTNOTE = "Free up to 1,000 devices. No card needed.";
const NOTES = {
install: "one line, really",
tryIt: "tick one off here",
/** Once something's ticked, keyed by the device it landed on. */
synced: { laptop: "and it's on the laptop already", phone: "and it's on the phone already" },
};
const LIST_TITLE = "Launch checklist";
const TASKS = [
{ id: "changelog", label: "Write the changelog", done: true },
{ id: "video", label: "Record the demo video", done: false },
{ id: "beta", label: "Email the beta list", done: false },
{ id: "ship", label: "Ship v3.2", done: false },
];
/** Round trips shown under each device as the visitor ticks things off. */
const LATENCIES = [38, 41, 33, 44, 36, 39];
/* ── Section ─────────────────────────────────────────────── */
type Device = "phone" | "laptop";
export function JotHero() {
const [done, setDone] = React.useState(() => new Set(TASKS.filter((task) => task.done).map((task) => task.id)));
const [last, setLast] = React.useState<{ id: string; from: Device; n: number } | null>(null);
const [copied, setCopied] = React.useState(false);
const installRef = React.useRef<HTMLButtonElement>(null);
const toggle = (id: string, from: Device) => {
setDone((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
setLast((current) => ({ id, from, n: (current?.n ?? 0) + 1 }));
};
const copy = () => {
navigator.clipboard?.writeText(INSTALL).then(
() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
},
() => {},
);
};
// Before anything is ticked, the note points at the phone's first open row; after, at the
// laptop's copy of whatever the visitor just changed.
const firstOpen = TASKS.find((task) => !done.has(task.id))?.id ?? TASKS[0].id;
const noteTarget = last
? `[data-row="${last.from === "phone" ? "laptop" : "phone"}-${last.id}"]`
: `[data-row="phone-${firstOpen}"]`;
const latency = LATENCIES[(last?.n ?? 0) % LATENCIES.length];
return (
<section
data-jot-scope
style={{ "--accent-light": ACCENT.light, "--accent-dark": ACCENT.dark } as React.CSSProperties}
className="relative overflow-hidden bg-white text-zinc-950 [--accent:var(--accent-light)] dark:bg-zinc-950 dark:text-white dark:[--accent:var(--accent-dark)]"
>
<header className="mx-auto flex h-16 max-w-6xl items-center justify-between px-6">
<a href={BRAND.href} className="flex items-center gap-2 rounded-md text-[15px] font-semibold tracking-tight focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-(--accent)">
<ShoalMark className="size-6 text-(--accent)" />
{BRAND.name}
</a>
<nav className="hidden items-center gap-7 text-sm text-zinc-600 md:flex dark:text-zinc-400">
{NAV_LINKS.map((link) => (
<a key={link.label} href={link.href} className="rounded-sm hover:text-zinc-950 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-(--accent) dark:hover:text-white">
{link.label}
</a>
))}
</nav>
<a href={SIGN_IN.href} className="rounded-full px-3.5 py-1.5 text-sm font-medium ring-1 ring-zinc-950/10 hover:bg-zinc-950/[0.03] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--accent) dark:ring-white/15 dark:hover:bg-white/[0.05]">
{SIGN_IN.label}
</a>
</header>
<div className="mx-auto grid max-w-6xl items-center gap-x-10 gap-y-16 px-6 pt-12 pb-24 sm:pt-16 lg:grid-cols-[minmax(0,1fr)_minmax(0,1.15fr)] lg:pt-20">
<div>
<a
href={ANNOUNCEMENT.href}
className="inline-flex items-center gap-2 rounded-full bg-zinc-950/[0.04] py-1 pr-3 pl-1 text-xs font-medium text-zinc-700 ring-1 ring-zinc-950/[0.06] hover:bg-zinc-950/[0.06] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--accent) dark:bg-white/[0.06] dark:text-zinc-300 dark:ring-white/10"
>
<span className="rounded-full bg-(--accent) px-2 py-0.5 text-white dark:text-zinc-950">{ANNOUNCEMENT.tag}</span>
{ANNOUNCEMENT.text}
<span aria-hidden>→</span>
</a>
<h1 className="mt-6 text-4xl font-semibold tracking-[-0.035em] text-balance sm:text-6xl">
{HEADLINE} <span className="text-zinc-400 dark:text-zinc-500">{HEADLINE_QUIET}</span>
</h1>
<p className="mt-6 max-w-lg text-base leading-relaxed text-pretty text-zinc-600 sm:text-lg dark:text-zinc-400">
{SUBLINE}
</p>
<div className="mt-9 flex flex-wrap items-center gap-3">
<a
href={PRIMARY_CTA.href}
className="rounded-full bg-(--accent) px-5 py-2.5 text-sm font-semibold text-white transition-opacity hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--accent) dark:text-zinc-950"
>
{PRIMARY_CTA.label}
</a>
<div className="relative">
<button
ref={installRef}
type="button"
onClick={copy}
className="group flex items-center gap-3 rounded-full bg-zinc-950/[0.04] py-2.5 pr-3 pl-4 font-mono text-[13px] ring-1 ring-zinc-950/[0.08] hover:bg-zinc-950/[0.06] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--accent) dark:bg-white/[0.05] dark:ring-white/10 dark:hover:bg-white/[0.08]"
>
<span>
<span className="text-zinc-400 select-none">$ </span>
{INSTALL}
</span>
<span className="text-zinc-400 group-hover:text-zinc-700 dark:group-hover:text-zinc-200">
{copied ? <CheckIcon className="size-4 text-(--accent)" /> : <CopyIcon className="size-4" />}
</span>
<span className="sr-only" aria-live="polite">
{copied ? "Copied" : "Copy the install command"}
</span>
</button>
<Jot
to={installRef}
from="left"
at="right"
bend={-0.5}
tilt={-2}
trigger="mount"
delay={0.4}
className="absolute top-1/2 left-full mt-4 ml-9 hidden max-w-none whitespace-nowrap text-zinc-600 xl:block dark:text-zinc-300"
>
{NOTES.install}
</Jot>
</div>
</div>
<p className="mt-5 text-sm text-zinc-500 dark:text-zinc-400">{FOOTNOTE}</p>
</div>
{/* Two devices on one list, with a clear channel between them for the note's arrow. Tick a
row on either and its copy on the other flashes as it lands. */}
<div className="relative mx-auto w-full max-w-[37rem] pt-20">
<Jot
to={noteTarget}
from="bottom"
at={last?.from === "phone" ? "right" : "left"}
bend={last?.from === "phone" ? 0.3 : -0.3}
tilt={-1.5}
trigger="mount"
delay={1.2}
className="absolute top-0 left-[57%] z-20 hidden max-w-none -translate-x-1/2 whitespace-nowrap text-zinc-600 md:block dark:text-zinc-300"
>
{last ? NOTES.synced[last.from === "phone" ? "laptop" : "phone"] : NOTES.tryIt}
</Jot>
<div className="flex items-center justify-center gap-10">
<Laptop done={done} flash={last?.from === "phone" ? last : null} latency={latency} onToggle={(id) => toggle(id, "laptop")} />
<Phone done={done} flash={last?.from === "laptop" ? last : null} latency={latency} onToggle={(id) => toggle(id, "phone")} />
</div>
</div>
</div>
</section>
);
}
/* ── The two devices ─────────────────────────────────────── */
type DeviceProps = {
done: Set<string>;
flash: { id: string; n: number } | null;
latency: number;
onToggle: (id: string) => void;
};
function Laptop({ done, flash, latency, onToggle }: DeviceProps) {
return (
<div className="hidden min-w-0 flex-1 overflow-hidden rounded-xl bg-white shadow-[0_24px_60px_-24px_rgb(0_0_0/0.35)] ring-1 ring-zinc-950/10 sm:block dark:bg-zinc-900 dark:ring-white/10">
<div className="flex h-8 items-center gap-1.5 border-b border-zinc-950/[0.06] bg-zinc-50 px-3 dark:border-white/[0.06] dark:bg-white/[0.03]">
<span className="size-2.5 rounded-full bg-[#ff5f57]" />
<span className="size-2.5 rounded-full bg-[#febc2e]" />
<span className="size-2.5 rounded-full bg-[#28c840]" />
<span className="ml-auto text-[11px] font-medium text-zinc-500 dark:text-zinc-400">Halden Lab</span>
<span className="ml-auto w-[38px]" />
</div>
<div className="p-4">
<p className="px-2 text-sm font-semibold">{LIST_TITLE}</p>
<ul className="mt-2.5 space-y-0.5">
{TASKS.map((task) => (
<Row key={task.id} device="laptop" task={task} checked={done.has(task.id)} flash={flash} onToggle={onToggle} compact />
))}
</ul>
<Status latency={latency} />
</div>
</div>
);
}
function Phone({ done, flash, latency, onToggle }: DeviceProps) {
return (
<div className="relative w-[14.5rem] shrink-0 rounded-[2.4rem] bg-zinc-950 p-2 shadow-[0_30px_60px_-20px_rgb(0_0_0/0.45)] ring-1 ring-black/40 dark:ring-white/15">
<div className="overflow-hidden rounded-[1.9rem] bg-white dark:bg-zinc-900">
<div className="flex items-center justify-between px-6 pt-3 text-[11px] font-semibold">
<span>9:41</span>
<span className="h-4 w-16 rounded-full bg-zinc-950" />
<span className="flex gap-0.5">
<span className="h-2 w-0.5 rounded-sm bg-current" />
<span className="h-2.5 w-0.5 rounded-sm bg-current" />
<span className="h-3 w-0.5 rounded-sm bg-current" />
</span>
</div>
<div className="px-4 pt-5 pb-4">
<p className="text-[11px] font-medium text-zinc-500 dark:text-zinc-400">Halden Lab</p>
<p className="mt-0.5 text-lg font-semibold tracking-tight">{LIST_TITLE}</p>
<ul className="mt-3 space-y-1">
{TASKS.map((task) => (
<Row key={task.id} device="phone" task={task} checked={done.has(task.id)} flash={flash} onToggle={onToggle} />
))}
</ul>
<Status latency={latency} />
</div>
</div>
</div>
);
}
function Row({
device,
task,
checked,
flash,
onToggle,
compact,
}: {
device: Device;
task: (typeof TASKS)[number];
checked: boolean;
flash: { id: string; n: number } | null;
onToggle: (id: string) => void;
compact?: boolean;
}) {
const flashing = flash?.id === task.id;
return (
<li data-row={`${device}-${task.id}`} className="relative">
{flashing ? (
<motion.span
key={flash.n}
aria-hidden
initial={{ opacity: 1 }}
animate={{ opacity: 0 }}
transition={{ duration: 1.4, ease: [0.22, 1, 0.36, 1], delay: 0.08 }}
className="pointer-events-none absolute inset-0 rounded-lg bg-(--accent)/15"
/>
) : null}
<label
className={`relative flex cursor-pointer items-center gap-2.5 rounded-lg px-2 has-focus-visible:outline-2 has-focus-visible:outline-(--accent) ${compact ? "py-1.5 text-[13px]" : "py-2.5 text-sm"}`}
>
<input type="checkbox" checked={checked} onChange={() => onToggle(task.id)} className="peer sr-only" />
<span
aria-hidden
className={`grid size-[18px] shrink-0 place-items-center rounded-full ring-1 transition-colors duration-150 ${
checked ? "bg-(--accent) ring-(--accent)" : "ring-zinc-300 dark:ring-zinc-600"
}`}
>
{checked ? <CheckIcon className="size-3 text-white dark:text-zinc-950" /> : null}
</span>
<span className={checked ? "text-zinc-400 line-through decoration-zinc-300 dark:text-zinc-500 dark:decoration-zinc-600" : ""}>
{task.label}
</span>
</label>
</li>
);
}
function Status({ latency }: { latency: number }) {
return (
<p className="mt-3 flex items-center gap-1.5 px-2 text-[11px] text-zinc-500 tabular-nums dark:text-zinc-400">
<span className="size-1.5 rounded-full bg-emerald-500" />
Synced in {latency} ms
</p>
);
}
/* ── Icons ───────────────────────────────────────────────── */
function ShoalMark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden>
<path d="M3 8.5c3-2.4 6-2.4 9 0s6 2.4 9 0" fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="round" />
<path d="M3 15.5c3-2.4 6-2.4 9 0s6 2.4 9 0" fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="round" opacity={0.5} />
</svg>
);
}
function CopyIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={1.5} className={className} aria-hidden>
<rect x="5.5" y="5.5" width="8" height="8" rx="1.5" />
<path d="M10.5 5.5V4a1.5 1.5 0 0 0-1.5-1.5H4A1.5 1.5 0 0 0 2.5 4v5A1.5 1.5 0 0 0 4 10.5h1.5" />
</svg>
);
}
function CheckIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth={2} className={className} aria-hidden>
<path d="m3.5 8.5 3 3 6-7" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
Live preview and the components it's built with: https://keyframeui.com/blocks/hero-sections/jot-hero