Pastel
A self-contained soft-pastel theme preset — a generously rounded card with heading, body, stat, tag, and buttons in a low-contrast candy palette over a dreamy tinted gradient, with soft shadows, drifting color orbs, and optional grain. A keyboard-operable radiogroup of hue chips (Mint / Peach / Lavender / Sky) re-tokens the whole panel live; the orbs pin to a calm static pose under reduced motion.
motionfree
"use client";
import { motion } from "motion/react";
import * as React from "react";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
import { cn } from "@/lib/utils";
export interface PastelPalette {
/** Outer soft gradient — top stop. */
base: string;
/** Outer soft gradient — bottom stop. */
baseAlt: string;
/** Card surface fill. */
surface: string;
/** Nested surface (stat, input, chips) fill. */
surfaceSoft: string;
/** Primary accent — filled button, active chip, stat figure. */
primary: string;
/** Text/icon color that sits on `primary`. */
onPrimary: string;
/** Secondary accent — the second floating orb + tag outline. */
accent: string;
/** Heading + body text. */
text: string;
/** Muted / secondary text. */
muted: string;
/** Hairline borders around the card and nested surfaces. */
border: string;
}
export interface PastelPreset {
/** Stable id used for controlled selection and React keys. */
id: string;
/** Chip label shown in the switcher. */
label: string;
/** Token set applied when this preset is active. */
palette: PastelPalette;
}
/** Built-in hue families — a candy spread across cool + warm pastels. */
const PRESETS: PastelPreset[] = [
{
id: "mint",
label: "Mint",
palette: {
base: "#eafbf3",
baseAlt: "#d6f3e8",
surface: "#f7fffb",
surfaceSoft: "#e9f9f1",
primary: "#4fd1a5",
onPrimary: "#ffffff",
accent: "#8fe0cd",
text: "#2f5d4f",
muted: "#6f9488",
border: "#cfeee1",
},
},
{
id: "peach",
label: "Peach",
palette: {
base: "#fff2ec",
baseAlt: "#ffe1d3",
surface: "#fff9f6",
surfaceSoft: "#ffece3",
primary: "#ff9e7d",
onPrimary: "#ffffff",
accent: "#ffc7a6",
text: "#6b3f34",
muted: "#a9776a",
border: "#ffd8c7",
},
},
{
id: "lavender",
label: "Lavender",
palette: {
base: "#f3effc",
baseAlt: "#e6ddfa",
surface: "#faf8ff",
surfaceSoft: "#efe9fc",
primary: "#a78bfa",
onPrimary: "#ffffff",
accent: "#c9b8fb",
text: "#4a3d6b",
muted: "#8073a6",
border: "#ded3f5",
},
},
{
id: "sky",
label: "Sky",
palette: {
base: "#ecf5ff",
baseAlt: "#d7eaff",
surface: "#f6fbff",
surfaceSoft: "#e6f2ff",
primary: "#6cb8f6",
onPrimary: "#ffffff",
accent: "#a3d3f9",
text: "#33506b",
muted: "#6f92ac",
border: "#cee6fb",
},
},
];
export interface PastelProps
extends Omit<React.ComponentPropsWithoutRef<"section">, "onChange"> {
/** Controlled active preset id (matches a preset's `id`). */
variant?: string;
/** Initial preset id when uncontrolled. @default "mint" (first preset) */
defaultVariant?: string;
/** Fires with the next preset id whenever the switcher changes. */
onVariantChange?: (id: string) => void;
/** Override the built-in preset set (chips + palettes). @default the four built-in hue families */
presets?: PastelPreset[];
/** Token overrides merged over the active preset — makes any palette reachable. */
colors?: Partial<PastelPalette>;
/** Orb-drift speed multiplier. @default 1 */
speed?: number;
/** Floating-orb opacity/presence multiplier. @default 1 */
intensity?: number;
/** Freeze the orbs at their resting pose. @default false */
paused?: boolean;
/** Subtle film grain over the card. @default true */
grain?: boolean;
/** Small pill above the heading. @default "Just for you" */
tagline?: string;
/** Rounded display heading. @default "Sweet spot" */
heading?: string;
/** Body line under the heading. @default "Soft surfaces, gentle color. Pick a hue." */
description?: string;
/** Stat figure. @default "24" */
statValue?: string;
/** Stat unit / label. @default "day streak" */
statLabel?: string;
/** Small outline tag. @default "cozy" */
tag?: string;
/** Filled button label. @default "Get started" */
primaryLabel?: string;
/** Soft button label. @default "Maybe later" */
secondaryLabel?: string;
/** Accessible label for the panel region. @default "Soft pastel theme preset" */
label?: string;
/** Accessible label for the switcher group. @default "Hue family" */
switcherLabel?: string;
}
interface Orb {
left: number;
top: number;
size: number;
hue: "primary" | "accent";
dx: number;
dy: number;
dur: number;
delay: number;
}
/** Deterministic PRNG so the orb scatter matches on server and client. */
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** Tiny fractal-noise tile for the optional grain — static, so SSR-safe. */
const GRAIN_URL =
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E\")";
/**
* Pastel — a self-contained soft-pastel theme preset. A generously rounded card
* with a heading, body, stat, tag, and two buttons, painted in a low-contrast
* candy palette over a dreamy tinted gradient, with soft shadows and a couple of
* gently drifting color orbs behind the content. A keyboard-operable radiogroup
* of hue chips (Mint / Peach / Lavender / Sky) re-tokens the whole panel live;
* the palette is fully exposed via `presets` and `colors`. Reduced motion (and
* `paused`) pins the orbs to a calm static pose — no drift, never a blank frame.
*/
export function Pastel({
variant,
defaultVariant,
onVariantChange,
presets,
colors,
speed = 1,
intensity = 1,
paused = false,
grain = true,
tagline = "Just for you",
heading = "Sweet spot",
description = "Soft surfaces, gentle color. Pick a hue.",
statValue = "24",
statLabel = "day streak",
tag = "cozy",
primaryLabel = "Get started",
secondaryLabel = "Maybe later",
label = "Soft pastel theme preset",
switcherLabel = "Hue family",
className,
style,
...rest
}: PastelProps) {
const reducedMotion = useReducedMotion();
const list = presets && presets.length > 0 ? presets : PRESETS;
const isControlled = variant !== undefined;
const [internal, setInternal] = React.useState(defaultVariant ?? list[0].id);
const activeId = isControlled ? variant : internal;
const active = list.find((p) => p.id === activeId) ?? list[0];
const t: PastelPalette = { ...active.palette, ...colors };
const spd = Math.max(0.1, speed);
const amt = Math.max(0, intensity);
const still = reducedMotion || paused;
const orbs = React.useMemo<Orb[]>(() => {
const rnd = mulberry32(0x9a57);
return [
{ hue: "primary" as const, base: 0 },
{ hue: "accent" as const, base: 1 },
{ hue: "primary" as const, base: 2 },
].map(({ hue }) => ({
hue,
left: 8 + rnd() * 64,
top: 6 + rnd() * 60,
size: 120 + rnd() * 90,
dx: (rnd() * 2 - 1) * 22,
dy: (rnd() * 2 - 1) * 18,
dur: 9 + rnd() * 6,
delay: rnd() * 2.5,
}));
}, []);
const activeIdx = Math.max(
0,
list.findIndex((p) => p.id === activeId)
);
const chipRefs = React.useRef<(HTMLButtonElement | null)[]>([]);
const select = React.useCallback(
(i: number) => {
const next = list[i];
if (!next) return;
if (!isControlled) setInternal(next.id);
onVariantChange?.(next.id);
},
[list, isControlled, onVariantChange]
);
const onSwitcherKey = React.useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
let next = activeIdx;
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
next = (activeIdx + 1) % list.length;
break;
case "ArrowLeft":
case "ArrowUp":
next = (activeIdx - 1 + list.length) % list.length;
break;
case "Home":
next = 0;
break;
case "End":
next = list.length - 1;
break;
default:
return;
}
e.preventDefault();
select(next);
chipRefs.current[next]?.focus();
},
[activeIdx, list, select]
);
const vars = {
"--pp": t.primary,
"--pon": t.onPrimary,
"--pacc": t.accent,
"--psurface": t.surface,
"--psoft": t.surfaceSoft,
"--ptext": t.text,
"--pmuted": t.muted,
"--pborder": t.border,
background: `linear-gradient(160deg, ${t.base} 0%, ${t.baseAlt} 100%)`,
} as React.CSSProperties;
const focusRing =
"outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--pp)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--psurface)]";
return (
<section
aria-label={label}
data-crucible="pastel"
data-variant={active.id}
className={cn("relative isolate w-full max-w-sm", className)}
style={style}
{...rest}
>
<div
className="relative isolate flex w-full flex-col overflow-hidden rounded-3xl p-1.5"
style={{
...vars,
boxShadow:
"0 1px 2px rgba(60,50,90,0.06), 0 18px 40px -18px rgba(80,70,120,0.35)",
}}
>
{/* Drifting soft orbs — decorative. */}
<div aria-hidden className="pointer-events-none absolute inset-0 z-0">
{orbs.map((o, i) => (
<motion.span
key={`orb-${i}`}
className="absolute rounded-full"
style={{
left: `${o.left}%`,
top: `${o.top}%`,
width: o.size,
height: o.size,
background: `radial-gradient(circle at 40% 38%, ${
o.hue === "primary" ? "var(--pp)" : "var(--pacc)"
}, transparent 68%)`,
opacity: Math.min(1, 0.5 * amt),
filter: "blur(26px)",
}}
animate={
still
? { x: 0, y: 0 }
: { x: [0, o.dx, 0], y: [0, o.dy, 0] }
}
transition={
still
? { duration: 0 }
: {
duration: o.dur / spd,
delay: o.delay,
repeat: Infinity,
ease: "easeInOut",
}
}
/>
))}
</div>
{/* Card surface. */}
<div
className="relative z-10 flex flex-col gap-4 rounded-[1.15rem] p-5"
style={{
background: "color-mix(in srgb, var(--psurface) 82%, transparent)",
border: "1px solid var(--pborder)",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.7)",
backdropFilter: "blur(6px)",
WebkitBackdropFilter: "blur(6px)",
}}
>
<div className="flex flex-col gap-2">
<span
className="w-fit rounded-full px-2.5 py-0.5 text-[0.65rem] font-semibold"
style={{
background: "var(--psoft)",
color: "var(--pp)",
border: "1px solid var(--pborder)",
}}
>
{tagline}
</span>
<h3
className="text-2xl font-bold leading-tight tracking-tight"
style={{ color: "var(--ptext)" }}
>
{heading}
</h3>
<p
className="text-sm leading-relaxed"
style={{ color: "var(--pmuted)" }}
>
{description}
</p>
</div>
{/* Stat + tag */}
<div className="flex items-center gap-3">
<div
className="flex items-baseline gap-1.5 rounded-2xl px-3 py-2"
style={{
background: "var(--psoft)",
border: "1px solid var(--pborder)",
}}
>
<span
className="text-2xl font-bold tabular-nums"
style={{ color: "var(--pp)" }}
>
{statValue}
</span>
<span
className="text-[0.7rem] font-medium"
style={{ color: "var(--pmuted)" }}
>
{statLabel}
</span>
</div>
<span
className="ml-auto rounded-full px-3 py-1 text-xs font-semibold"
style={{
color: "var(--pp)",
border: "1px solid color-mix(in srgb, var(--pp) 45%, transparent)",
}}
>
{tag}
</span>
</div>
{/* Buttons */}
<div className="flex gap-2.5">
<button
type="button"
className={cn(
"rounded-2xl px-4 py-2.5 text-sm font-semibold transition-transform active:scale-[0.97]",
focusRing
)}
style={{
background: "var(--pp)",
color: "var(--pon)",
boxShadow:
"0 8px 18px -8px color-mix(in srgb, var(--pp) 75%, transparent)",
}}
>
{primaryLabel}
</button>
<button
type="button"
className={cn(
"rounded-2xl px-4 py-2.5 text-sm font-semibold transition-colors active:scale-[0.97]",
focusRing
)}
style={{
background: "var(--psoft)",
color: "var(--ptext)",
border: "1px solid var(--pborder)",
}}
>
{secondaryLabel}
</button>
</div>
{/* Hue switcher — a real radiogroup with roving focus. */}
<div
role="radiogroup"
aria-label={switcherLabel}
onKeyDown={onSwitcherKey}
className="mt-0.5 flex flex-wrap gap-2"
>
{list.map((p, i) => {
const selected = p.id === active.id;
return (
<button
key={p.id}
ref={(el) => {
chipRefs.current[i] = el;
}}
type="button"
role="radio"
aria-checked={selected}
tabIndex={selected ? 0 : -1}
onClick={() => select(i)}
className={cn(
"flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold transition-transform active:scale-[0.97]",
focusRing
)}
style={
selected
? {
background: "var(--pp)",
color: "var(--pon)",
boxShadow:
"0 4px 12px -4px color-mix(in srgb, var(--pp) 70%, transparent)",
}
: {
background: "var(--psoft)",
color: "var(--pmuted)",
border: "1px solid var(--pborder)",
}
}
>
<span
aria-hidden
className="h-2 w-2 rounded-full"
style={{
background: p.palette.primary,
boxShadow: selected
? "0 0 0 2px rgba(255,255,255,0.65)"
: "none",
}}
/>
{p.label}
</button>
);
})}
</div>
</div>
{/* Optional film grain over the whole card. */}
{grain && (
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-20 rounded-3xl mix-blend-soft-light"
style={{ backgroundImage: GRAIN_URL, opacity: 0.05 }}
/>
)}
</div>
</section>
);
}
Installation
CLI
npx shadcn@latest add @crucible/pastelManual — install dependencies, then copy the source
npm install motionProps
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | string | Controlled active preset id (matches a preset's id). | |
| defaultVariant | string | "mint" (first preset) | Initial preset id when uncontrolled. |
| onVariantChange | (id: string) => void | Fires with the next preset id whenever the switcher changes. | |
| presets | PastelPreset[] | the four built-in hue families | Override the built-in preset set (chips + palettes). |
| colors | Partial<PastelPalette> | Token overrides merged over the active preset — makes any palette reachable. | |
| speed | number | 1 | Orb-drift speed multiplier. |
| intensity | number | 1 | Floating-orb opacity/presence multiplier. |
| paused | boolean | false | Freeze the orbs at their resting pose. |
| grain | boolean | true | Subtle film grain over the card. |
| tagline | string | "Just for you" | Small pill above the heading. |
| heading | string | "Sweet spot" | Rounded display heading. |
| description | string | "Soft surfaces, gentle color. Pick a hue." | Body line under the heading. |
| statValue | string | "24" | Stat figure. |
| statLabel | string | "day streak" | Stat unit / label. |
| tag | string | "cozy" | Small outline tag. |
| primaryLabel | string | "Get started" | Filled button label. |
| secondaryLabel | string | "Maybe later" | Soft button label. |
| label | string | "Soft pastel theme preset" | Accessible label for the panel region. |
| switcherLabel | string | "Hue family" | Accessible label for the switcher group. |
Also accepts all props of Omit<React.ComponentPropsWithoutRef<"section">, "onChange"> — they pass through to the underlying element. | |||
Honors prefers-reduced-motion with a designed static fallback, and passes className through.