# Pastel — Theme for React

> 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.

- Page: https://crucibleui.com/components/themes/pastel
- Category: [themes](https://crucibleui.com/components/themes)
- Tier: free (full source)
- Engine: motion
- npm dependencies: motion

## Install

```bash
npx shadcn@latest add @crucible/pastel
```

Registry JSON: https://crucibleui.com/r/pastel.json

Import from `@/components/crucible/pastel`.

## Props (`PastelProps`)

| 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: `Omit<React.ComponentPropsWithoutRef<"section">, "onChange">`.

## Usage example

```tsx
"use client";

import * as React from "react";

import { Pastel } from "@/components/crucible/pastel";

export default function PastelDemo() {
  const [variant, setVariant] = React.useState("mint");

  return (
    <div className="relative isolate flex h-full min-h-[360px] w-full items-center justify-center overflow-hidden rounded-xl bg-neutral-950 p-6">
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(255,255,255,0.05),transparent_70%)]"
      />
      <div className="relative z-10 flex flex-col items-center gap-5">
        <Pastel variant={variant} onVariantChange={setVariant} />
        <p className="text-center text-sm text-neutral-500">
          Switch the look — click a hue, or focus it and use the arrow keys.
        </p>
      </div>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.

## Source (`pastel.tsx`)

```tsx
"use client";

import { motion } from "motion/react";
import * as React from "react";

import { useReducedMotion } from "@/hooks/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 — tints the second floating orb. */
  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>
  );
}
```
