DesignPass.dev

ui

Free

IsometricButton

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.

// updates

Know when new components drop

A short email when something new lands in the library. No noise, unsubscribe anytime.