ui
FreeIsometricButton
A floating 3D prism button with a glowing underside and floor reflection — hover drops it toward the floor with a spring bounce, click presses it down. Pure CSS 3D, no canvas.
// preview
// settings
Float height (px)
Hover height (px)
Thickness (px)
Corner radius (px)
Font size (px)
▸advanced
Camera tilt X (deg)
Camera spin Z (deg)
Hover bounce duration (s)
Hover glow brightness
Reflection opacity
Hover scale
Hover text glow (px)
// source
TSJS
/*!
* IsometricButton — a DesignPass.dev component by Ernest Liu
* Docs & live playground: https://designpass.dev/components/isometric-button
* MIT licensed — keep this notice in copies and adaptations.
*/
/* IsometricButton — a rounded rectangular prism with a glowing bottom face,
floating above the floor in an isometric view. The reflection is a
duplicate of the glowing face, flipped so the lit side faces up, blurred,
lower opacity. All faces are siblings inside .iso-btn-obj (preserve-3d +
isometric rotation) and differ only by translateZ. Hover swaps the
"current" custom properties to their hover variants, moving prism and
reflection toward each other while the glow brightens.
The real <button> (.iso-btn-hit) is an invisible unrotated overlay so the
hover/click target stays stable while the prism animates.
Every `--iso-btn-*` custom property below is a tunable default; the
component's `settings` prop writes the same properties inline, which wins
over these class-level declarations. */
.iso-btn-scene {
/* rest / hover / press values (tunables) */
--iso-btn-gap-rest: 26px;
--iso-btn-gap-hover: 9px;
--iso-btn-gap-press: 4px;
--iso-btn-thick: 14px;
--iso-btn-radius: 18px;
--iso-btn-rot-x: 54deg;
--iso-btn-rot-z: -42deg;
--iso-btn-shift-y: 8px;
--iso-btn-speed: 0.35s;
/* Rest→hover uses a bouncy spring curve and its own (longer) duration;
hover→rest falls back to the smooth curve + --iso-btn-speed.
--iso-btn-ease-hover-default is swapped to a linear() spring in the
@supports block below (Bézier overshoot fallback for old browsers). */
--iso-btn-ease-rest: cubic-bezier(0.22, 1, 0.36, 1);
--iso-btn-ease-hover-default: cubic-bezier(0.3, 1.8, 0.4, 0.9);
--iso-btn-ease-hover: var(--iso-btn-ease-hover-default);
--iso-btn-speed-hover: 0.7s;
--iso-btn-scale-rest: 1;
--iso-btn-scale-hover: 1;
--iso-btn-glow-brightness-rest: 1;
--iso-btn-glow-brightness-hover: 1.5;
--iso-btn-glow-size-rest: 1;
--iso-btn-glow-size-hover: 1.45;
--iso-btn-refl-opacity-rest: 0.4;
--iso-btn-refl-opacity-hover: 0.85;
--iso-btn-refl-blur-rest: 10px;
--iso-btn-refl-blur-hover: 7px;
--iso-btn-refl-size-rest: 1;
--iso-btn-refl-size-hover: 1.3;
/* Reflection brightness follows the underside glow unless overridden. */
--iso-btn-refl-brightness-rest: calc(var(--iso-btn-glow-brightness-rest) * 0.95);
--iso-btn-refl-brightness-hover: calc(var(--iso-btn-glow-brightness-hover) * 0.95);
--iso-btn-font-size-rest: 1rem;
--iso-btn-font-size-hover: 1rem;
--iso-btn-text-color-rest: #ffffff;
--iso-btn-text-color-hover: #ffffff;
--iso-btn-text-glow-size-rest: 0px;
--iso-btn-text-glow-size-hover: 10px;
--iso-btn-text-glow-color: #a05cff;
--iso-btn-top-color: #1c1626;
--iso-btn-glow-color-a: #6366f1;
--iso-btn-glow-color-b: #a05cff;
--iso-btn-glow-color-c: #ff5fd2;
--iso-btn-focus-color: #7df9ff;
/* current-state values (swapped on hover/press) */
--iso-btn-gap: var(--iso-btn-gap-rest);
--iso-btn-scale: var(--iso-btn-scale-rest);
--iso-btn-glow-brightness: var(--iso-btn-glow-brightness-rest);
--iso-btn-glow-size: var(--iso-btn-glow-size-rest);
--iso-btn-refl-opacity: var(--iso-btn-refl-opacity-rest);
--iso-btn-refl-blur: var(--iso-btn-refl-blur-rest);
--iso-btn-refl-size: var(--iso-btn-refl-size-rest);
--iso-btn-refl-brightness: var(--iso-btn-refl-brightness-rest);
--iso-btn-font-size: var(--iso-btn-font-size-rest);
--iso-btn-text-color: var(--iso-btn-text-color-rest);
--iso-btn-text-glow-size: var(--iso-btn-text-glow-size-rest);
/* current transition timing (swapped to spring on hover) */
--iso-btn-ease: var(--iso-btn-ease-rest);
--iso-btn-dur: var(--iso-btn-speed);
position: relative;
display: block;
}
/* Default rest→hover spring, generated from a damped-oscillator model
(stiffness 240, damping 12, mass 1). */
@supports (transition-timing-function: linear(0, 1)) {
.iso-btn-scene {
/* prettier-ignore */
--iso-btn-ease-hover-default: linear(0, 0.062, 0.2196, 0.4301, 0.6555, 0.8654, 1.0387, 1.1642, 1.2388, 1.2666, 1.2559, 1.2175, 1.163, 1.1028, 1.0455, 0.9971, 0.9612, 0.9388, 0.9293, 0.9305, 0.9396, 0.9536, 0.9696, 0.9852, 0.9987, 1.0089, 1.0155, 1.0187, 1.0188, 1.0167, 1.0131, 1.0089, 1.0047, 1.001, 0.998, 0.9961, 0.9951, 0.9949, 0.9954, 0.9963, 0.9974, 0.9986, 0.9996, 1.0004, 1.001, 1.0013, 1.0014, 1.0013, 1);
}
}
.iso-btn-obj {
position: absolute;
inset: 0;
transform: translateY(var(--iso-btn-shift-y)) rotateX(var(--iso-btn-rot-x))
rotateZ(var(--iso-btn-rot-z)) scale(var(--iso-btn-scale));
transform-style: preserve-3d;
transition: transform var(--iso-btn-dur) var(--iso-btn-ease);
pointer-events: none;
}
.iso-btn-obj > * {
position: absolute;
inset: 0;
border-radius: var(--iso-btn-radius);
transition:
transform var(--iso-btn-dur) var(--iso-btn-ease),
opacity var(--iso-btn-dur) ease,
filter var(--iso-btn-dur) ease,
box-shadow var(--iso-btn-dur) ease,
font-size var(--iso-btn-dur) ease,
color var(--iso-btn-dur) ease,
text-shadow var(--iso-btn-dur) ease;
}
/* Top face of the prism, carries the label. */
.iso-btn-top {
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(255, 255, 255, 0.09);
background: linear-gradient(
135deg,
color-mix(in srgb, var(--iso-btn-top-color) 100%, #fff 4%) 0%,
color-mix(in srgb, var(--iso-btn-top-color) 72%, #000 28%) 75%
);
color: var(--iso-btn-text-color);
font-weight: 600;
font-size: var(--iso-btn-font-size);
letter-spacing: 0.02em;
/* Doubled layer makes the outer glow read at small blur radii. */
text-shadow:
0 0 var(--iso-btn-text-glow-size) var(--iso-btn-text-glow-color),
0 0 calc(var(--iso-btn-text-glow-size) * 2) var(--iso-btn-text-glow-color);
transform: translateZ(calc(var(--iso-btn-gap) + var(--iso-btn-thick)));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
/* Rounded side walls, faked with stacked slices between bottom and top.
Spread-only (no blur) shadows fill the gaps between slices while keeping
the depth edge crisp. */
.iso-btn-side {
background: color-mix(in srgb, var(--iso-btn-top-color) 60%, #000 40%);
transform: translateZ(
calc(var(--iso-btn-gap) + var(--iso-btn-thick) * var(--i) / 15)
);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--iso-btn-top-color) 60%, #000 40%);
}
/* Glowing bottom face of the prism. */
.iso-btn-glow {
background: linear-gradient(
120deg,
var(--iso-btn-glow-color-a) 0%,
var(--iso-btn-glow-color-b) 50%,
var(--iso-btn-glow-color-c) 110%
);
transform: translateZ(var(--iso-btn-gap));
box-shadow:
0 0 calc(16px * var(--iso-btn-glow-size)) calc(2px * var(--iso-btn-glow-size))
color-mix(in srgb, var(--iso-btn-glow-color-b) 80%, transparent),
0 0 calc(44px * var(--iso-btn-glow-size)) calc(6px * var(--iso-btn-glow-size))
color-mix(in srgb, var(--iso-btn-glow-color-b) 40%, transparent);
filter: brightness(var(--iso-btn-glow-brightness))
saturate(calc(0.85 + 0.15 * var(--iso-btn-glow-brightness)));
}
/* Reflection: the glowing face duplicated, flipped below the floor,
blurred and faded. */
.iso-btn-reflection {
background: linear-gradient(
120deg,
var(--iso-btn-glow-color-a) 0%,
var(--iso-btn-glow-color-b) 50%,
var(--iso-btn-glow-color-c) 110%
);
transform: translateZ(calc(-1 * var(--iso-btn-gap))) scaleZ(-1);
box-shadow:
0 0 calc(20px * var(--iso-btn-refl-size)) calc(4px * var(--iso-btn-refl-size))
color-mix(in srgb, var(--iso-btn-glow-color-b) 85%, transparent),
0 0 calc(56px * var(--iso-btn-refl-size)) calc(10px * var(--iso-btn-refl-size))
color-mix(in srgb, var(--iso-btn-glow-color-b) 45%, transparent);
filter: blur(var(--iso-btn-refl-blur)) brightness(var(--iso-btn-refl-brightness));
opacity: var(--iso-btn-refl-opacity);
}
/* Invisible hit target — the actual form button. Pinned at the top face's
REST height (not the animated current height): if it tracked the moving
face, the hover boundary would shift under a stationary pointer and
oscillate. Kept constant, the pointer target aligns with the face you
see at rest and stays stable during the animation. */
.iso-btn-hit {
position: absolute;
inset: -6px;
border: 0;
background: transparent;
color: transparent;
cursor: pointer;
border-radius: var(--iso-btn-radius);
transform: translateZ(calc(var(--iso-btn-gap-rest) + var(--iso-btn-thick)));
}
.iso-btn-hit:focus-visible {
outline: 2px solid var(--iso-btn-focus-color);
outline-offset: 2px;
}
.iso-btn-hit:disabled {
cursor: not-allowed;
}
.iso-btn-scene:has(.iso-btn-hit:disabled) .iso-btn-obj {
opacity: 0.7;
}
/* Hover / focus: swap current values to their hover variants. */
.iso-btn-scene:has(.iso-btn-hit:not(:disabled):hover),
.iso-btn-scene:has(.iso-btn-hit:not(:disabled):focus-visible) {
--iso-btn-gap: var(--iso-btn-gap-hover);
--iso-btn-scale: var(--iso-btn-scale-hover);
--iso-btn-glow-brightness: var(--iso-btn-glow-brightness-hover);
--iso-btn-glow-size: var(--iso-btn-glow-size-hover);
--iso-btn-refl-opacity: var(--iso-btn-refl-opacity-hover);
--iso-btn-refl-blur: var(--iso-btn-refl-blur-hover);
--iso-btn-refl-size: var(--iso-btn-refl-size-hover);
--iso-btn-refl-brightness: var(--iso-btn-refl-brightness-hover);
--iso-btn-font-size: var(--iso-btn-font-size-hover);
--iso-btn-text-color: var(--iso-btn-text-color-hover);
--iso-btn-text-glow-size: var(--iso-btn-text-glow-size-hover);
/* Entering hover bounces; leaving re-uses the smooth rest easing. */
--iso-btn-ease: var(--iso-btn-ease-hover);
--iso-btn-dur: var(--iso-btn-speed-hover);
}
/* Click: almost touching the floor — snappy, no bounce. */
.iso-btn-scene:has(.iso-btn-hit:not(:disabled):active) {
--iso-btn-gap: var(--iso-btn-gap-press);
--iso-btn-ease: ease-out;
--iso-btn-dur: 0.1s;
}
@media (prefers-reduced-motion: reduce) {
.iso-btn-obj,
.iso-btn-obj > * {
transition: none;
}
}
/* Visually hidden but readable by screen readers (the visible label lives
on the decorative top face, which is aria-hidden). */
.iso-btn-sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/*!
* IsometricButton — a DesignPass.dev component by Ernest Liu
* Docs & live playground: https://designpass.dev/components/isometric-button
* MIT licensed — keep this notice in copies and adaptations.
*/
"use client";
import type { ButtonHTMLAttributes, CSSProperties } from "react";
import "./IsometricButton.css";
/**
* Main knobs 90% of uses need. Everything else lives in `advanced`.
* All values map to CSS custom properties consumed by IsometricButton.css;
* anything left undefined falls back to the stylesheet default.
*/
export interface IsometricButtonSettings {
/** Height of the prism above the floor at rest, e.g. "26px". */
gapRest?: string;
/** Height above the floor while hovered, e.g. "9px". */
gapHover?: string;
/** Prism thickness, e.g. "14px". */
thickness?: string;
/** Corner radius of all faces, e.g. "18px". */
radius?: string;
/** Base color of the prism body. */
topColor?: string;
/** Glow gradient stops (underside + reflection). */
glowColorA?: string;
glowColorB?: string;
glowColorC?: string;
/** Label color at rest. */
textColor?: string;
/** Label font size, e.g. "22px". */
fontSize?: string;
/** Everything else — camera, timing, reflection, text glow, per-state
* overrides. Collapsed here so the common surface stays small. */
advanced?: IsometricButtonAdvancedSettings;
}
export interface IsometricButtonAdvancedSettings {
/** Height above the floor while pressed, e.g. "4px". */
gapPress?: string;
/** Isometric camera angles. */
rotateX?: string;
rotateZ?: string;
/** Vertical offset of the whole scene, e.g. "8px". */
shiftY?: string;
/** Animation duration for hover→rest (and press), e.g. "0.35s". */
speed?: string;
/** Animation duration for rest→hover (the bouncy leg), e.g. "0.7s". */
speedHover?: string;
/** Timing function for hover→rest, any CSS easing. */
easeRest?: string;
/** Timing function for rest→hover; defaults to a linear() spring. */
easeHover?: string;
/** Overall prism scale at rest / hover (unitless, 1 = 100%). */
scaleRest?: number;
scaleHover?: number;
/** Underside glow brightness at rest / hover (unitless multiplier). */
glowBrightnessRest?: number;
glowBrightnessHover?: number;
/** Underside glow spread at rest / hover (unitless multiplier). */
glowSizeRest?: number;
glowSizeHover?: number;
/** Floor reflection opacity at rest / hover (0-1). */
reflectionOpacityRest?: number;
reflectionOpacityHover?: number;
/** Floor reflection blur at rest / hover, e.g. "10px". */
reflectionBlurRest?: string;
reflectionBlurHover?: string;
/** Floor reflection glow spread at rest / hover (unitless multiplier). */
reflectionSizeRest?: number;
reflectionSizeHover?: number;
/** Floor reflection brightness at rest / hover (unitless multiplier).
* Defaults to tracking the underside glow brightness. */
reflectionBrightnessRest?: number;
reflectionBrightnessHover?: number;
/** Label font size while hovered (defaults to fontSize). */
fontSizeHover?: string;
/** Label color while hovered (defaults to textColor). */
textColorHover?: string;
/** Outer text glow radius at rest / hover, e.g. "0px" / "10px". */
textGlowSizeRest?: string;
textGlowSizeHover?: string;
/** Outer text glow color. */
textGlowColor?: string;
/** Focus outline color. */
focusColor?: string;
}
const MAIN_VARS: Record<string, string> = {
gapRest: "--iso-btn-gap-rest",
gapHover: "--iso-btn-gap-hover",
thickness: "--iso-btn-thick",
radius: "--iso-btn-radius",
topColor: "--iso-btn-top-color",
glowColorA: "--iso-btn-glow-color-a",
glowColorB: "--iso-btn-glow-color-b",
glowColorC: "--iso-btn-glow-color-c",
textColor: "--iso-btn-text-color-rest",
fontSize: "--iso-btn-font-size-rest",
};
const ADVANCED_VARS: Record<string, string> = {
gapPress: "--iso-btn-gap-press",
rotateX: "--iso-btn-rot-x",
rotateZ: "--iso-btn-rot-z",
shiftY: "--iso-btn-shift-y",
speed: "--iso-btn-speed",
speedHover: "--iso-btn-speed-hover",
easeRest: "--iso-btn-ease-rest",
easeHover: "--iso-btn-ease-hover",
scaleRest: "--iso-btn-scale-rest",
scaleHover: "--iso-btn-scale-hover",
glowBrightnessRest: "--iso-btn-glow-brightness-rest",
glowBrightnessHover: "--iso-btn-glow-brightness-hover",
glowSizeRest: "--iso-btn-glow-size-rest",
glowSizeHover: "--iso-btn-glow-size-hover",
reflectionOpacityRest: "--iso-btn-refl-opacity-rest",
reflectionOpacityHover: "--iso-btn-refl-opacity-hover",
reflectionBlurRest: "--iso-btn-refl-blur-rest",
reflectionBlurHover: "--iso-btn-refl-blur-hover",
reflectionSizeRest: "--iso-btn-refl-size-rest",
reflectionSizeHover: "--iso-btn-refl-size-hover",
reflectionBrightnessRest: "--iso-btn-refl-brightness-rest",
reflectionBrightnessHover: "--iso-btn-refl-brightness-hover",
fontSizeHover: "--iso-btn-font-size-hover",
textColorHover: "--iso-btn-text-color-hover",
textGlowSizeRest: "--iso-btn-text-glow-size-rest",
textGlowSizeHover: "--iso-btn-text-glow-size-hover",
textGlowColor: "--iso-btn-text-glow-color",
focusColor: "--iso-btn-focus-color",
};
function settingsToStyle(settings: IsometricButtonSettings): CSSProperties {
const style: Record<string, string> = {};
const { advanced, ...main } = settings;
for (const [key, value] of Object.entries(main)) {
if (value === undefined) continue;
style[MAIN_VARS[key]] = String(value);
}
// Hover text/font default to the rest values so the main API stays small.
if (main.textColor !== undefined && advanced?.textColorHover === undefined) {
style["--iso-btn-text-color-hover"] = String(main.textColor);
}
if (main.fontSize !== undefined && advanced?.fontSizeHover === undefined) {
style["--iso-btn-font-size-hover"] = String(main.fontSize);
}
for (const [key, value] of Object.entries(advanced ?? {})) {
if (value === undefined) continue;
style[ADVANCED_VARS[key]] = String(value);
}
return style as CSSProperties;
}
export interface IsometricButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** Classes applied to the outer scene wrapper — size it here (the scene
* has no intrinsic size), e.g. "w-48 h-14". */
wrapperClassName?: string;
/** Visual overrides; see IsometricButtonSettings. */
settings?: IsometricButtonSettings;
}
/** Number of stacked slices used to fake the prism's rounded side walls.
* Slices sit ~1px apart (thickness / count) with crisp, unblurred edge
* shadows — fewer, farther-apart slices needed blurred shadows to hide
* the gaps, which made the whole depth edge look fuzzy. */
const SIDE_LAYERS = 15;
/**
* An isometric rounded rectangular prism with a glowing bottom face,
* floating above the floor. On hover the prism and its reflection move
* toward each other (with a spring bounce) and the glow brightens; on
* press it drops to just above the floor.
*
* The prism is purely decorative: the real <button> is an invisible,
* unrotated overlay covering the scene, so the hover/click target stays
* stable while the prism animates.
*/
export default function IsometricButton({
children,
wrapperClassName = "",
settings,
...buttonProps
}: IsometricButtonProps) {
return (
<span
className={`iso-btn-scene ${wrapperClassName}`}
style={settings ? settingsToStyle(settings) : undefined}
>
<span className="iso-btn-obj" aria-hidden>
<span className="iso-btn-reflection" />
<span className="iso-btn-glow" />
{Array.from({ length: SIDE_LAYERS }, (_, i) => (
<span key={i} className="iso-btn-side" style={{ "--i": i } as CSSProperties} />
))}
<span className="iso-btn-top">{children}</span>
</span>
<button {...buttonProps} className="iso-btn-hit">
<span className="iso-btn-sr-only">{children}</span>
</button>
</span>
);
}
// install
npx shadcn@latest add "https://designpass.dev/r/IsometricButton-TS-CSS.json"Need the license details? Read the component license.