Live Session
An evening of loud guitars and louder crowds.
React components with ink outlines, hard offset shadows and spring animations on a cream background. Built with React, Tailwind CSS v4 and Motion.
$ npx shadcn add @kettle/pill-buttonKettle UI is a shadcn registry. The CLI copies each component into your project with its npm packages, the colour tokens and the keyframes it needs, so you own the code and can edit it.
Skip this if your project already has a components.json.
npx shadcn@latest initPut this in components.json so you can use the short @kettle names.
// components.json
{
"registries": {
"@kettle": "https://raw.githubusercontent.com/apurvamukherjee/kettle-ui/main/public/r/{name}.json"
}
}One at a time, or everything at once with kettle-all.
npx shadcn@latest add @kettle/pill-button @kettle/cursorEvery item also installs straight from its URL.
npx shadcn@latest add https://raw.githubusercontent.com/apurvamukherjee/kettle-ui/main/public/r/kettle-all.jsonThe display and mono fonts are Bricolage Grotesque and Fragment Mono. Load them with next/font and set the--font-bricolage and --font-fragment-monovariables, or the theme falls back to system fonts. PillButton uses next/link, so it needs Next.js. Everything else is plain React.
Most surfaces are paper and ink, and each accent colour has its own job. Tap a swatch to copy its hex, or the token name to copy the CSS variable.
Every colour is a CSS variable mapped into Tailwind's theme.
:root {
--paper: #fbf6ea; --paper-2: #f3ead4;
--ink: #1a1714;
--tea: #1fa34a; --tea-deep: #0f6b30;
--tunes: #ff4b1f; --strings: #ff4fb8;
--lilac: #8a7cff; --sun: #ffd43b;
--sky: #7fd1f5;
}
@theme inline {
--color-paper: var(--paper);
--color-ink: var(--ink);
--color-tunes: var(--tunes);
/* …one per token → bg-tunes, text-ink, border-ink */
--shadow-hard: 4px 4px 0 0 var(--ink);
--shadow-hard-lg: 8px 8px 0 0 var(--ink);
--ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
}Three families: a variable display grotesque, a warm sans for reading and a mono for labels.
Aa Tea
Bricolage Grotesque · DisplayAa Steeped slowly
Manrope · BodyAa 0123
Fragment Mono · Labelsimport { Bricolage_Grotesque, Manrope, Fragment_Mono } from "next/font/google";
const display = Bricolage_Grotesque({ variable: "--font-bricolage", subsets: ["latin"], axes: ["opsz", "wdth"] });
const sans = Manrope({ variable: "--font-manrope", subsets: ["latin"] });
const mono = Fragment_Mono({ variable: "--font-fragment-mono", subsets: ["latin"], weight: "400" });
<h1 className="font-display font-extrabold uppercase">Kettle UI</h1>
<p className="font-sans">Body copy in Manrope.</p>
<span className="font-mono uppercase tracking-widest">Eyebrow</span>Display
clamp(3.75rem, 15vw, 10.5rem)
Steep it slowly
H1
4.5rem / 0.9
Steep it slowly
H2
2.25rem
Steep it slowly
H3
1.5rem
Steep it slowly
Body
1.125rem / 1.6
Steep it slowly
Caption
0.75rem mono
Steep it slowly
Shadows are solid ink offsets with no blur. Elements lift on hover and press flat when tapped.
Hover or tap the tiles.
<div className="rounded-[2rem] border-2 border-ink shadow-hard" />
<div className="rounded-[2rem] border-2 border-ink shadow-hard-lg" />
{/* the lift-and-press interaction used everywhere */}
<button className="border-2 border-ink shadow-hard transition-[transform,box-shadow]
hover:-translate-x-0.5 hover:-translate-y-0.5 hover:shadow-hard-lg
active:translate-x-1 active:translate-y-1 active:shadow-none" />--ease-bounce
cubic-bezier(0.34, 1.56, 0.64, 1)
Hover or press the card
--ease-out-expo
cubic-bezier(0.16, 1, 0.3, 1)
Hover or press the card
Tilted mono eyebrow for section labels, statuses and tags.
import { Chip } from "@/components/kit/Chip";
<Chip>Schedule</Chip>
<Chip tone="tea">Open</Chip>
<Chip tone="tunes" tilt={false}>Sold out</Chip>Labelled input with an ink outline. Lifts on focus; turns red with a message when invalid.
Type a bad email to see the error state.
We only send product updates.
import { Field } from "@/components/kit/Field";
<Field label="Your name" placeholder="Alex Rivera" />
<Field
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
hint="We only send product updates."
error={invalid ? "That doesn't look like an email yet." : undefined}
/>Poster card. On hover a colour wash floods up and the description slides in. On touch screens the description is always shown.
An evening of loud guitars and louder crowds.
Morning trails, fresh air and a finish-line breakfast.
A stage for creators and digital storytellers.
import { EventCard } from "@/components/kit/EventCard";
<EventCard
title="Live Session"
accent="var(--tunes)"
image="/posters/live-session.jpg"
desc="An evening of loud guitars and louder crowds."
footer={<Chip tone="paper">Register</Chip>}
/>Instant-photo frame with a caption strip. Drop any image, video or illustration in.
import { Polaroid } from "@/components/kit/Polaroid";
<Polaroid caption="Studio ✶ Day 1" className="rotate-2">
<img src="/photo.jpg" alt="" className="size-full object-cover" />
</Polaroid>Springs in tilted, idles with a gentle bob and, on mouse devices, can be dragged and snaps home when let go.
Grab one with your mouse.
import { Sticker } from "@/components/motion/Sticker";
{/* springs in, idles with a bob, drag it and it snaps home (mouse only) */}
<Sticker rotate={-10}>
<div className="rounded-full border-2 border-ink bg-sun px-5 py-3 font-display font-extrabold shadow-hard">
Brewed fresh ☕
</div>
</Sticker>Infinite ticker. On desktop its speed and direction react to scroll velocity and it slows on hover; on touch screens it is a pure CSS animation.
Scroll the page to push it.
import { Marquee } from "@/components/motion/Marquee";
{/* scroll-velocity aware on desktop, pure CSS on touch screens */}
<Marquee baseVelocity={-4} className="border-y-2 border-ink bg-sun py-4">
<span className="mx-6 font-display text-4xl font-extrabold uppercase">Tea ✶ Tunes ✶ Strings ✶</span>
</Marquee>Eyebrow chip, a giant word-by-word masked reveal title and an optional subtitle. Pass objects to colour single words.
Five days of talks, workshops and good coffee.
import { SectionHeading } from "@/components/kit/SectionHeading";
<SectionHeading
eyebrow="Schedule"
title={["Event", { text: "Itinerary", className: "text-tunes" }]}
subtitle="Five days of talks, workshops and good coffee."
/>Scroll-triggered entrances that run on the compositor, so they stay smooth on iOS.
Fades up on scroll
import { Reveal, Stagger } from "@/components/motion/Reveal";
<Reveal y={40}>
<p>Fades up on scroll</p>
</Reveal>
<Stagger className="grid grid-cols-4 gap-3" stagger={0.1}>
<Tile /><Tile /><Tile /><Tile />
</Stagger>Six custom cursors plus the native one. The one you pick here follows you around the whole site. Mark any element with data-cursor="Label" and the cursor turns into that label over it.
Custom cursors only run with a mouse or trackpad. On touch screens the native behaviour stays as it is.
data-cursor="View"Over this box Leaf Pick turns into the label.import { Cursor } from "@/components/cursor/Cursor";
// once, in your root layout
<Cursor variant="ring" /> // "leaf" | "ring" | "blob" | "trail" | "spark" | "kettle"
// anywhere: the cursor becomes a label over this element
<a href="/work" data-cursor="View">Case study</a>The animated hero illustration: a toy train crossing rolling green hills below a snowy peak, with drifting clouds, steam puffs and a cup of tea playing tunes. Every loop pauses while off-screen.
Pure SVG, coloured entirely by tokens, so it re-themes with the palette.
on prop pauses everythingimport { HeroScene } from "@/components/illustrations/HeroScene";
import { useOnScreen } from "@/components/motion/useOnScreen";
function Hero() {
const [ref, on] = useOnScreen(); // pauses every loop while off-screen
return (
<div ref={ref} className="relative aspect-[4/3] overflow-hidden rounded-2xl">
<HeroScene on={on} />
</div>
);
}The little engine on its own: chugging bob, spinning wheels and smoke puffs, all in CSS.
import { ToyTrain } from "@/components/illustrations/ToyTrain";
{/* pure CSS animation, needs the .toy-train keyframes from globals.css */}
<ToyTrain className="h-16" />Ticket-stub tabs with the toy train springing along the track to the selected stop. Use it for itineraries or multi-step forms.
Pick a step.
Next stop → Plan
import { TrainStepper } from "@/components/illustrations/TrainStepper";
const stops = [
{ label: "Plan", caption: "Step 01" },
{ label: "Board", caption: "Step 02" },
{ label: "Arrive", caption: "Step 03" },
];
const [stop, setStop] = useState(0);
<TrainStepper stops={stops} value={stop} onChange={setStop} />