Brutal
A self-contained neo-brutalist theme preset — a flat white card with hard 3px black borders, a solid blur-free offset drop-shadow, chunky mono type, a loud accent block, a stat, a rotated corner sticker, and two buttons. Interactive blocks do the classic brutalist nudge toward their shadow on hover and press. A keyboard-operable radiogroup of accent swatches re-tokens the whole panel live; the palette is fully exposed via presets and colors. Pure CSS — reduced motion pins every block to its raised resting pose.
"use client";
import * as React from "react";
import { useReducedMotion } from "@/registry/default/hooks/use-reduced-motion/use-reduced-motion";
import { cn } from "@/lib/utils";
export interface BrutalPalette {
/** Card surface — the flat block everything sits on. */
surface: string;
/** Panel backdrop behind the card (the mat). */
base: string;
/** Foreground text (near-black by default). */
text: string;
/** Muted / secondary text. */
muted: string;
/** Hard border + drop-shadow color — pure black in classic brutalism. */
ink: string;
/** The loud accent — filled blocks, primary button, active chip. */
accent: string;
/** Text/ink drawn on top of the accent (kept high-contrast). */
accentText: string;
}
export interface BrutalPreset {
/** 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: BrutalPalette;
}
/** Near-black ink shared by every built-in preset. */
const INK = "#111111";
/** Loud accents on a white block with black ink — swap the whole vibe with one chip. */
const PRESETS: BrutalPreset[] = [
{
id: "acid",
label: "Acid",
palette: {
surface: "#ffffff",
base: "#f4f1e6",
text: INK,
muted: "#5b5b52",
ink: INK,
accent: "#ffde17",
accentText: INK,
},
},
{
id: "bubble",
label: "Bubble",
palette: {
surface: "#ffffff",
base: "#ffe9f3",
text: INK,
muted: "#6b5560",
ink: INK,
accent: "#ff4d97",
accentText: "#ffffff",
},
},
{
id: "volt",
label: "Volt",
palette: {
surface: "#ffffff",
base: "#e2f6fb",
text: INK,
muted: "#4d5f63",
ink: INK,
accent: "#22d3ee",
accentText: INK,
},
},
];
export interface BrutalProps
extends Omit<React.ComponentPropsWithoutRef<"section">, "onChange"> {
/** Controlled active preset id (matches a preset's `id`). */
variant?: string;
/** Initial preset id when uncontrolled. @default "acid" (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 three built-in accents */
presets?: BrutalPreset[];
/** Token overrides merged over the active preset — makes any palette reachable. */
colors?: Partial<BrutalPalette>;
/** Drop-shadow offset in px — the depth of the block. @default 6 */
offset?: number;
/** Border / shadow thickness in px. @default 3 */
border?: number;
/** Freeze the tactile press-nudge (also auto-frozen under reduced motion). @default false */
paused?: boolean;
/** Small mono kicker above the heading. @default "NO. 03 / SERIES" */
kicker?: string;
/** Chunky display heading. @default "PRESS HARD" */
heading?: string;
/** Body line under the heading. @default "Hard edges, honest shadows. Pick your loud." */
description?: string;
/** Big stat figure. @default "24" */
statValue?: string;
/** Stat unit / label. @default "SHIPS / DAY" */
statLabel?: string;
/** Small corner sticker text. @default "NEW" */
sticker?: string;
/** Filled (accent) button label. @default "Get it" */
primaryLabel?: string;
/** Outline (white) button label. @default "Later" */
secondaryLabel?: string;
/** Accessible label for the panel region. @default "Neo-brutalist theme preset" */
label?: string;
/** Accessible label for the switcher group. @default "Accent" */
switcherLabel?: string;
}
/**
* Brutal — a self-contained neo-brutalist theme preset. A flat white card with
* hard 3px black borders and a solid, blur-free offset drop-shadow, chunky mono
* type, a loud accent block, a stat, a rotated corner sticker, and two buttons.
* Interactive elements do the classic brutalist nudge on hover/press — they
* shift toward their shadow until they sit flush. A keyboard-operable radiogroup
* of accent chips re-tokens the whole panel live; the palette is fully exposed
* via `presets` and `colors`. The nudge is pure CSS transition, so reduced
* motion (or `paused`) simply pins every block to its raised resting pose.
*/
export function Brutal({
variant,
defaultVariant,
onVariantChange,
presets,
colors,
offset = 6,
border = 3,
paused = false,
kicker = "NO. 03 / SERIES",
heading = "PRESS HARD",
description = "Hard edges, honest shadows. Pick your loud.",
statValue = "24",
statLabel = "SHIPS / DAY",
sticker = "NEW",
primaryLabel = "Get it",
secondaryLabel = "Later",
label = "Neo-brutalist theme preset",
switcherLabel = "Accent",
className,
style,
...rest
}: BrutalProps) {
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: BrutalPalette = { ...active.palette, ...colors };
// The nudge is a CSS transition on transform + box-shadow. When motion is off
// we drop the transition and the hover/active nudge utilities so every block
// holds its raised resting pose — never a flat, shadowless frame.
const nudge = !reducedMotion && !paused;
const off = Math.max(0, offset);
const bw = Math.max(1, border);
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 = {
"--br-surface": t.surface,
"--br-base": t.base,
"--br-text": t.text,
"--br-muted": t.muted,
"--br-ink": t.ink,
"--br-accent": t.accent,
"--br-accent-text": t.accentText,
"--br-off": `${off}px`,
"--br-off-hover": `${Math.round(off / 2)}px`,
"--br-bw": `${bw}px`,
background: "var(--br-base)",
} as React.CSSProperties;
const focusRing =
"outline-none focus-visible:ring-[3px] focus-visible:ring-[color:var(--br-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--br-surface)]";
// Raised block: full offset shadow at rest, nudges toward the shadow on
// hover, sits flush on press. Gated so reduced motion holds the raised pose.
const raised = cn(
"border-[length:var(--br-bw)] border-[color:var(--br-ink)]",
"shadow-[var(--br-off)_var(--br-off)_0_0_var(--br-ink)]",
nudge &&
cn(
"transition-[transform,box-shadow] duration-100 ease-out",
"hover:translate-x-[var(--br-off-hover)] hover:translate-y-[var(--br-off-hover)]",
"hover:shadow-[var(--br-off-hover)_var(--br-off-hover)_0_0_var(--br-ink)]",
"active:translate-x-[var(--br-off)] active:translate-y-[var(--br-off)]",
"active:shadow-[0_0_0_0_var(--br-ink)]"
)
);
return (
<section
aria-label={label}
data-crucible="brutal"
data-variant={active.id}
className={cn(
"relative isolate w-full max-w-sm p-[calc(var(--br-off)+2px)]",
className
)}
style={{ ...vars, ...style }}
{...rest}
>
{/* The card — a flat block with hard border + solid offset shadow. */}
<div
className={cn(
"relative flex w-full flex-col gap-4 p-5",
"border-[length:var(--br-bw)] border-[color:var(--br-ink)]",
"shadow-[var(--br-off)_var(--br-off)_0_0_var(--br-ink)]"
)}
style={{ background: "var(--br-surface)", color: "var(--br-text)" }}
>
{/* Rotated corner sticker — decorative flair. */}
{sticker ? (
<span
aria-hidden
className="absolute -right-3 -top-3 -rotate-6 select-none px-2 py-0.5 font-mono text-[0.62rem] font-black uppercase tracking-widest border-[length:var(--br-bw)] border-[color:var(--br-ink)] shadow-[3px_3px_0_0_var(--br-ink)]"
style={{ background: "var(--br-accent)", color: "var(--br-accent-text)" }}
>
{sticker}
</span>
) : null}
{/* Kicker + heading + body */}
<div className="flex flex-col gap-1.5">
<p
className="font-mono text-[0.62rem] font-bold uppercase tracking-[0.28em]"
style={{ color: "var(--br-muted)" }}
>
{kicker}
</p>
<h3
className="text-4xl font-black uppercase leading-[0.95] tracking-tight"
style={{ color: "var(--br-text)" }}
>
{heading}
</h3>
<p className="text-sm font-medium" style={{ color: "var(--br-muted)" }}>
{description}
</p>
</div>
{/* Accent stat block + tag row */}
<div className="flex items-stretch gap-3">
<div
className="flex items-center gap-2 px-3 py-2 border-[length:var(--br-bw)] border-[color:var(--br-ink)]"
style={{ background: "var(--br-accent)", color: "var(--br-accent-text)" }}
>
<span className="text-2xl font-black tabular-nums leading-none">
{statValue}
</span>
<span className="font-mono text-[0.58rem] font-bold uppercase leading-tight tracking-wider">
{statLabel}
</span>
</div>
</div>
{/* Buttons */}
<div className="flex gap-3">
<button
type="button"
className={cn(
"px-4 py-2 text-sm font-black uppercase tracking-wide",
raised,
focusRing
)}
style={{ background: "var(--br-accent)", color: "var(--br-accent-text)" }}
>
{primaryLabel}
</button>
<button
type="button"
className={cn(
"px-4 py-2 text-sm font-black uppercase tracking-wide",
raised,
focusRing
)}
style={{ background: "var(--br-surface)", color: "var(--br-text)" }}
>
{secondaryLabel}
</button>
</div>
{/* Accent switcher — a real radiogroup with roving focus. Selected chip
sits flush (pressed in); the rest stay raised on their shadow. */}
<div
role="radiogroup"
aria-label={switcherLabel}
onKeyDown={onSwitcherKey}
className="mt-1 flex flex-wrap items-center gap-1 border-t-[length:var(--br-bw)] border-dashed border-[color:color-mix(in_srgb,var(--br-ink)_25%,transparent)] pt-4"
>
<span
aria-hidden
className="mr-1 font-mono text-[0.6rem] font-bold uppercase tracking-widest"
style={{ color: "var(--br-muted)" }}
>
{switcherLabel}
</span>
{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}
aria-label={p.label}
tabIndex={selected ? 0 : -1}
onClick={() => select(i)}
title={p.label}
className={cn(
"h-7 w-7 border-[length:var(--br-bw)] border-[color:var(--br-ink)]",
nudge && "transition-[transform,box-shadow] duration-100 ease-out",
selected
? "translate-x-[3px] translate-y-[3px] shadow-[0_0_0_0_var(--br-ink)]"
: "shadow-[3px_3px_0_0_var(--br-ink)]",
focusRing
)}
style={{ background: p.palette.accent }}
>
{/* High-contrast selected marker that survives any accent. */}
<span
aria-hidden
className={cn(
"mx-auto block h-2 w-2 rounded-full border border-[color:var(--br-ink)]",
selected ? "opacity-100" : "opacity-0"
)}
style={{ background: "var(--br-ink)" }}
/>
</button>
);
})}
</div>
</div>
</section>
);
}
Installation
CLI
npx shadcn@latest add @crucible/brutalProps
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | string | Controlled active preset id (matches a preset's id). | |
| defaultVariant | string | "acid" (first preset) | Initial preset id when uncontrolled. |
| onVariantChange | (id: string) => void | Fires with the next preset id whenever the switcher changes. | |
| presets | BrutalPreset[] | the three built-in accents | Override the built-in preset set (chips + palettes). |
| colors | Partial<BrutalPalette> | Token overrides merged over the active preset — makes any palette reachable. | |
| offset | number | 6 | Drop-shadow offset in px — the depth of the block. |
| border | number | 3 | Border / shadow thickness in px. |
| paused | boolean | false | Freeze the tactile press-nudge (also auto-frozen under reduced motion). |
| kicker | string | "NO. 03 / SERIES" | Small mono kicker above the heading. |
| heading | string | "PRESS HARD" | Chunky display heading. |
| description | string | "Hard edges, honest shadows. Pick your loud." | Body line under the heading. |
| statValue | string | "24" | Big stat figure. |
| statLabel | string | "SHIPS / DAY" | Stat unit / label. |
| sticker | string | "NEW" | Small corner sticker text. |
| primaryLabel | string | "Get it" | Filled (accent) button label. |
| secondaryLabel | string | "Later" | Outline (white) button label. |
| label | string | "Neo-brutalist theme preset" | Accessible label for the panel region. |
| switcherLabel | string | "Accent" | 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.