Plinth

Stepped 3D cards that lift and breathe under the cursor, built to feel physical rather than snappy.

Overview

Plinth renders a horizontal arrangement of stepped cards in 3D space. The component owns all the spatial math — perspective, step offsets, six-face transforms, and the hover physics — so you only need to think about how each face should look.

Hover behavior is driven by two influence curves: a broad curve that subtly reshapes the entire row, and a narrow curve that lifts the card nearest the cursor. Both feed into motion springs, so the result feels weighted and physical, not snappy.

It works in any React 18 or 19 project. The component must run on the client (it uses the DOM and mouse events) — in Next.js App Router and similar frameworks, mark the parent file with "use client" or wrap it in a client component.

Installation

Plinth has one peer dependency: motion (the modern Motion library, formerly Framer Motion). Install both alongside React.

npm install @beauhawkinson/plinth motion

Peer versions: react ^18 || ^19, react-dom ^18 || ^19, motion ^12.

Quickstart

Import the component and render it. The default stylesheet ships bundled with the component and injects itself on mount — no separate CSS import to remember.

import { Plinth } from "@beauhawkinson/plinth";

export default function Example() {
  return <Plinth count={6} />;
}

To customize the look, see Styling.

In a Next.js App Router project

// app/page.tsx
import { Plinth } from "@beauhawkinson/plinth";

export default function Page() {
  return (
    <main className="flex min-h-screen items-center justify-center">
      <Plinth />
    </main>
  );
}

Anatomy

Each card is a six-faced 3D box. Every face is a real DOM element you can target. The data-face attribute on each one is part of the stable public API — it will not change between versions.

  • frontThe card you see. Style it with faceStyles.front or [data-face="front"].
  • backMirror of the front, used during deep tilts. Rarely visible.
  • topHeight equals depth. Visible when rotateX is negative (leaning back).
  • bottomHeight equals depth. Visible from below. Catches shadow tint.
  • left / rightWidth equals depth. Visible based on rotateY tilt.

The outer container is a <fieldset> element with class .plinth-stack-fieldset. Each pillar wrapper carries .plinth-card-wrapper, and every face has .plinth-face plus .plinth-face-{name}.

Props

All props are optional. The component renders correctly with no props at all.

Layout

Control the size and spacing of cards.

PropTypeDefaultDescription
countnumber12Number of cards in the stack.
cardWidthnumber384Width of each card face, in pixels.
cardHeightnumber160Height of each card face, in pixels.
depthnumbercardHeight × 0.2Thickness of each pillar (top/bottom/left/right face dimension).
peeknumbercardWidth ÷ 6How many pixels of each card show behind the next.
risenumbercardHeight × 0.2Vertical step offset between adjacent cards.

Camera

Control how the stack is viewed in 3D space.

PropTypeDefaultDescription
rotateXnumber-12Camera tilt on the X axis, in degrees (negative leans back).
rotateYnumber-45Camera tilt on the Y axis, in degrees (negative turns right).
perspectivenumber1200CSS perspective distance, in pixels. Lower = more dramatic depth.

Styling & Hover

Hand off appearance and interaction tuning.

PropTypeDefaultDescription
faceStylesFaceStyles{}Map of face name → className. Merges with default classes.
hoverHoverConfigSpring + scale + falloff settings. See Hover Physics below.
liftAmountnumber-(cardHeight × 0.02)Distance (px) a card travels upward at peak hover. Negative = up.
classNamestringClass applied to the outer fieldset element. Useful for scoping CSS.

Hover Physics

The hover effect comes from two overlapping influence curves centered on the cursor. Both curves are Gaussian — they peak at 1 over the cursor and fall off smoothly with distance.

  • The broad curve drives scaleDip across the whole row — usually a negative value, so cards squish slightly as the cursor moves over them.
  • The narrow curve drives scaleBoost and liftAmount — a tight peak that grows the hovered card and lifts it upward.

Both values then pass through a single motion spring, so animations feel weighted. Tune the spring for snappier or softer behavior.

<Plinth
  hover={{
    spring: { stiffness: 220, damping: 28, mass: 0.2 },
    scaleDip:      -0.15,  // adjacent cards squish slightly
    scaleBoost:     1.4,   // hovered card grows taller
    falloff:        1.6,   // broad zone of influence
    narrowFalloff:  2,     // tight zone for the lift
  }}
/>

HoverConfig

PropTypeDefaultDescription
springSpringOptions{ stiffness: 200, damping: 30, mass: 0.2 }Motion spring used for both the lift and scale animations.
scaleDipnumber-0.2Vertical scale change applied broadly across the stack. Negative values cause adjacent cards to squish slightly.
scaleBoostnumber1.618 (φ)Extra vertical scale on the card under the cursor.
falloffnumber1.618 (φ)How quickly the broad influence decays from the cursor. Higher = wider influence.
narrowFalloffnumber1.618 (φ)Tightness of the lift effect on the hovered card. Higher = tighter focus.

Styling

Plinth bakes a default stylesheet directly into the component bundle — no manual CSS import required. From there you have two clean ways to override it.

Default Look

Out of the box you get a pale-face / plum-border / soft-shadow look. The bundled stylesheet uses two CSS variables you can override on :root or any ancestor to retheme globally:

/* Override the two variables the bundled stylesheet
   uses for borders and shadow tinting. */
:root {
  --plum: oklch(0.65 0.18 280);  /* card borders */
  --soot: oklch(0.30 0.04 280);  /* underside + shadow */
}

For per-face or per-instance changes, use the two approaches below — both layer on top of the defaults with normal CSS specificity.

1. The faceStyles prop

Pass classes per face. Works with Tailwind, CSS modules, vanilla classes — anything that resolves to a className. These merge with the default classes, so you only override what you need.

<Plinth
  faceStyles={{
    front: "bg-amber-100 border border-amber-200",
    back: "bg-amber-50",
    top: "bg-amber-200",
    bottom: "bg-amber-300",
    left: "bg-amber-200/80",
    right: "bg-amber-200/80",
  }}
/>

2. data-face CSS selectors

Each face has a stable data-face attribute. Combine with a wrapper className to scope your rules and keep them out of the rest of your app.

/* styles.css */
.gold-stack [data-face="front"] {
  background-color: oklch(0.88 0.14 94);
  border: 1px solid oklch(0.82 0.12 94);
}

.gold-stack [data-face="top"] {
  background-color: oklch(0.85 0.16 94);
}

.gold-stack [data-face="left"],
.gold-stack [data-face="right"] {
  background-color: oklch(0.80 0.15 94);
}

.gold-stack [data-face="bottom"] {
  background-color: oklch(0.65 0.18 60);
}
import { Plinth } from "@beauhawkinson/plinth";
import "./styles.css";

export default function GoldStack() {
  return <Plinth count={6} className="gold-stack" />;
}

Sizing & Responsiveness

Plinth sizes itself to fit its cards. The total visible width is roughly cardWidth + (count − 1) × peek, so a stack of 8 cards at 384px width with the default peek of 64 takes about 832px.

The container does not shrink to fit narrower viewports automatically. For mobile-friendly layouts, swap count and cardWidth based on a media query:

"use client";

import { Plinth } from "@beauhawkinson/plinth";
import { useEffect, useState } from "react";

function useIsMobile() {
  const [isMobile, setIsMobile] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia("(max-width: 640px)");
    setIsMobile(mq.matches);
    const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
    mq.addEventListener("change", handler);
    return () => mq.removeEventListener("change", handler);
  }, []);
  return isMobile;
}

export default function Responsive() {
  const isMobile = useIsMobile();
  return (
    <Plinth
      count={isMobile ? 4 : 8}
      cardWidth={isMobile ? 240 : 384}
    />
  );
}

TypeScript

The package ships with full type definitions. All public types are exported from the package root:

import type {
  PlinthProps,
  HoverConfig,
  FaceStyles,
  FaceName,
} from "@beauhawkinson/plinth";

// FaceName = "front" | "back" | "top" | "bottom" | "left" | "right"
// FaceStyles = Partial<Record<FaceName, string>>

const myStyles: FaceStyles = {
  front: "bg-white border",
  top: "bg-stone-100",
};

Browser Support & Accessibility

  • Requires CSS 3D transforms (universally supported) and modern ResizeObserver (Safari 13.1+, Chrome 64+, Firefox 69+).
  • The default stylesheet uses oklch() colors — supported in Chrome 111+, Safari 15.4+, Firefox 113+. If you support older browsers, provide your own stylesheet with fallback colors.
  • The hover effect is mouse-driven. On touch devices the cards sit at their resting position with no animation — by design, since touch has no hover. Plan content accordingly if your stack is interactive.
  • Plinth is a visual / decorative component. The cards are not focusable, do not capture keyboard input, and do not announce themselves to screen readers. If you need accessible content, render it in a separate, semantic layer (e.g. an off-screen list) and let Plinth handle the visual.

Recipes

A heavily-customized stack with adjusted dimensions, camera, hover, and dark face styling:

<Plinth
  count={8}
  cardWidth={220}
  cardHeight={300}
  rotateX={-18}
  rotateY={-32}
  perspective={1400}
  hover={{
    spring: { stiffness: 220, damping: 28, mass: 0.2 },
    scaleDip: -0.15,
    scaleBoost: 1.4,
    falloff: 1.6,
    narrowFalloff: 2,
  }}
  faceStyles={{
    front: "bg-zinc-900 text-zinc-50",
    top: "bg-zinc-800",
    bottom: "bg-zinc-950",
    left: "bg-zinc-700",
    right: "bg-zinc-700",
  }}
/>

Roadmap

Planned improvements as the library evolves — no version commitments, just a public record of what's on the horizon.

  • prefers-reduced-motionDisable hover physics when the user has reduced motion enabled.
  • oklch fallbacksPair every oklch() declaration with an rgb/hex fallback so older browsers degrade gracefully.
  • Vertical orientationAn orientation="vertical" prop for stacks that descend top-to-bottom.
  • Touch-aware modeOpt-in ambient animation or tap-to-focus interaction for touch devices, since hover doesn't fire on touch.
  • Keyboard navigationAn interactive prop that makes the stack a single tabstop with arrow keys to move the lifted card.
  • renderCard propBring back the render prop for putting custom content inside each front face.

Have a request or a use case the current API doesn't cover? Open an issue on GitHub.