Pushed Shadow Button
A neo-brutalist button on a hard 5px shadow: hover lifts it 2px, a press lands it exactly on its shadow. 3px ink everywhere, 100ms linear, no blur.
Props
"use client";
import { useState } from "react";
import type { CSSProperties, KeyboardEvent, MouseEventHandler, ReactNode } from "react";
/**
* PushedShadowButton — a neo-brutalist button that behaves like a physical
* object sitting on its own shadow.
*
* The shadow is hard (no blur) and its far corner never moves. Point at the
* button and it lifts 2px toward you while the shadow grows from 5px to 7px;
* press it and it lands exactly on the shadow (translate 5px, shadow 0).
* Every move is 100ms linear: no easing, no overshoot, no scale.
*
* The press shows for mouse, touch, pen and keyboard alike (Enter and Space
* hold it down until the key comes up). The border, the shadow and the arrow's
* stroke are all the same 3px ink, because a brutalist surface has no blur to
* hide an inconsistent pixel in.
*
* Renders a link when given href, otherwise a button. Type reads
* var(--font-display) first, so a buyer's own next/font grotesque drops in.
*
* Needs Tailwind v4 (or v3.4+). No dependencies beyond React.
*/
const DISPLAY = 'var(--font-display, "Archivo", "Archivo Black", "Arial Black", "Helvetica Neue", Arial, sans-serif)';
/** The kit's flat fills. */
const FILLS = {
yellow: "#ffd23f",
coral: "#ff6b6b",
blue: "#74b9ff",
lime: "#b4f462",
pink: "#ff5fa2",
white: "#ffffff",
cream: "#fffdf5",
black: "#000000",
} as const;
export type PushedShadowColor = keyof typeof FILLS;
const HEX = /^#[0-9a-fA-F]{6}$/;
/** Relative luminance of #rrggbb, 0 to 1. */
function luminance(hex: string) {
const c = [1, 3, 5].map((i) => {
const v = parseInt(hex.slice(i, i + 2), 16) / 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
});
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
}
/** Links: strip tabs and newlines, refuse backslashes, allow http(s), mailto, tel and same-site paths. */
function safeHref(raw: string) {
const v = raw.replace(/[\t\n\r]/g, "").trim();
if (!v || v.includes("\\")) return "#";
if (/^(https?:|mailto:|tel:)/i.test(v)) return v;
if (/^[/#?]/.test(v) && !/^\/\//.test(v)) return v;
return "#";
}
export type PushedShadowButtonProps = {
children?: ReactNode;
/** Renders a link. */
href?: string;
/** A kit fill, or any #rrggbb. */
color?: PushedShadowColor | string;
/** Border, shadow and (on light fills) label colour, as #rrggbb. Use a light ink on dark pages. */
ink?: string;
size?: "sm" | "md" | "lg";
/** A 3px-stroke arrow after the label that steps 3px right on hover. */
arrow?: boolean;
/** Uppercase label. */
caps?: boolean;
/** Stretch to the container's width. */
block?: boolean;
/** Shows a stepped progress mark, sets aria-busy and ignores clicks. */
busy?: boolean;
busyLabel?: string;
type?: "button" | "submit" | "reset";
disabled?: boolean;
onClick?: MouseEventHandler<HTMLElement>;
className?: string;
};
export function PushedShadowButton({
children = "Start building",
href,
color = "yellow",
ink = "#000000",
size = "md",
arrow = false,
caps = false,
block = false,
busy = false,
busyLabel = "Working",
type = "button",
disabled = false,
onClick,
className = "",
}: PushedShadowButtonProps) {
const [pressed, setPressed] = useState(false);
const named = Object.prototype.hasOwnProperty.call(FILLS, color) ? FILLS[color as PushedShadowColor] : null;
const fill = named ?? (HEX.test(color) ? color : FILLS.yellow);
const line = HEX.test(ink) ? ink : "#000000";
// Dark fills take a cream label; everything else reads in the ink.
const label = luminance(fill) < 0.3 ? (luminance(line) < 0.3 ? "#fffdf5" : "#000000") : line;
const inert = disabled || busy;
const down = pressed && !inert;
const release = () => setPressed(false);
const onKeyDown = (e: KeyboardEvent<HTMLElement>) => {
if (e.repeat) return;
if (e.key === "Enter" || (e.key === " " && !href)) setPressed(true);
};
const cls =
"group relative inline-flex select-none items-center justify-center gap-2.5 whitespace-nowrap leading-none " +
"transition-[transform,translate,box-shadow] duration-100 ease-linear motion-reduce:transition-none " +
"focus-visible:![outline-offset:4px] focus-visible:![outline-style:dashed] focus-visible:![outline-width:3px] focus-visible:![border-radius:0] " +
(size === "sm" ? "h-10 px-4 text-[14px] " : size === "lg" ? "h-16 px-8 text-[20px] " : "h-12 px-6 text-[16px] ") +
(caps ? "uppercase tracking-[0.04em] " : "tracking-[-0.005em] ") +
(block ? "w-full " : "") +
(disabled
? "cursor-not-allowed shadow-none "
: busy
? "cursor-progress shadow-[5px_5px_0_0_var(--psb-ink)] "
: down
? "translate-x-[5px] translate-y-[5px] shadow-none "
: "shadow-[5px_5px_0_0_var(--psb-ink)] hover:-translate-x-[2px] hover:-translate-y-[2px] hover:shadow-[7px_7px_0_0_var(--psb-ink)] ") +
className;
const style = {
"--psb-ink": line,
fontFamily: DISPLAY,
fontWeight: 800,
color: label,
// Set inline so no global border-colour rule can repaint it.
border: "3px solid " + line,
outlineColor: line,
background: disabled
? "repeating-linear-gradient(135deg, " + line + "26 0 3px, transparent 3px 9px), " + fill
: fill,
opacity: disabled ? 0.72 : undefined,
} as CSSProperties;
const inner = (
<>
<span className={busy ? "opacity-0" : undefined}>{children}</span>
{arrow && !busy && (
<svg
aria-hidden
width={size === "lg" ? 22 : 18}
height={size === "lg" ? 16 : 14}
viewBox="0 0 18 14"
fill="none"
className="shrink-0 transition-transform duration-100 ease-linear group-hover:translate-x-[3px] motion-reduce:transition-none"
>
<path d="M0 7H15M9 1.5L15 7L9 12.5" stroke="currentColor" strokeWidth="3" strokeLinecap="square" strokeLinejoin="miter" />
</svg>
)}
{busy && (
<span className="absolute inset-0 flex items-center justify-center gap-[5px]" role="status" aria-label={busyLabel}>
{[0, 1, 2].map((i) => (
<span
key={i}
aria-hidden
className="psb-step size-[9px] motion-reduce:animate-none"
style={{ background: "currentColor", animationDelay: i * 300 + "ms" }}
/>
))}
<style>{"@keyframes psb-step{0%,32%{opacity:1}33%,100%{opacity:.18}}.psb-step{animation:psb-step 900ms steps(1,end) infinite}"}</style>
</span>
)}
</>
);
const handlers = {
onPointerDown: (e: { button: number }) => {
if (e.button === 0) setPressed(true);
},
onPointerUp: release,
onPointerLeave: release,
onPointerCancel: release,
onKeyDown,
onKeyUp: release,
onBlur: release,
};
if (href && !disabled) {
return (
<a
href={safeHref(href)}
onClick={
busy
? (e) => e.preventDefault()
: onClick
}
aria-busy={busy || undefined}
aria-disabled={busy || undefined}
className={cls}
style={style}
{...handlers}
>
{inner}
</a>
);
}
return (
<button
type={type}
disabled={disabled}
aria-busy={busy || undefined}
aria-disabled={busy || undefined}
onClick={busy ? (e) => e.preventDefault() : onClick}
className={cls}
style={style}
{...handlers}
>
{inner}
</button>
);
}
About this component
Most neo-brutalist buttons get the look and miss the physics: a blurred shadow, a 2px border beside a 4px one, an eased press that bounces. This one treats the shadow as a real object. The border, the shadow and the arrow's stroke are all the same 3px ink. Point at it and the button lifts 2px toward you while the shadow grows to 7px, so the shadow's far corner never moves; press it and it lands exactly on the shadow. Every move is 100 milliseconds, linear. The press is driven by state rather than :active, so it shows the same for a mouse, a finger, a pen and a held Enter or Space key. It comes in the kit's flat fills (yellow, coral, blue, white, black) or any hex, three sizes, with an optional arrow that steps 3px on hover, a busy state with a stepped progress mark, and a hatched disabled state. It renders a link when given href.
Curator’s note
Part of the Brutalism kit, drop 3, phase 10: the kit's primary control. Free: one element, CSS transitions and a pressed state.
Pairs well with
Matched on shared tags and category — the entries most likely to be used alongside this one.
Offset Shadow Card
componentsA neo-brutalist card on a hard shadow: it lifts 2px on hover and lands on the shadow when pressed. A flat drawn motif or a photo, a punched price tag, a sticker badge, 3px ink throughout.
Slab Stack Hero
heroesA neo-brutalist first screen: a slab headline beside a pile of hard-shadow cards you can grab and throw. Thrown cards fly off and cut to the bottom; the rest step up. Nothing eases.
Brutal Mega Menu
componentsA neo-brutalist bar whose menu is a full-screen index that cuts in and out in one frame: huge section rows between 3px rules, a hard inverted selection, links and a feature card beside it.