Iso Momentum
IllustrationsAn isometric slab field where every slab's height tracks pointer proximity and springs independently. The pointer flows through a motion value into each slab's path `d` string at 60fps — no React state, no re-renders while it moves. Pointer events, so it works under touch and pen, not just a mouse.
Move across the field — or drag, on touch. React never re-renders while it moves.
Falloff
Damping · 18
Slabs · 15
Damping under ~10 overshoots and wobbles; over ~30 it goes syrupy. The default is 18 — the difference between those numbers is the entire feel of the component.
components/ui/iso-momentum.tsx
"use client";
import * as React from "react";
import {
motion,
useMotionTemplate,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
type MotionValue,
type SpringOptions,
} from "motion/react";
import { cn } from "@/lib/utils";
/**
* IsoMomentum — an isometric slab field whose heights track the pointer.
*
* The whole component is one continuous signal path with **no React state**:
*
* pointermove → useMotionValue(x, in viewBox units)
* → useTransform(distance → target height, via the `falloff` prop)
* → useSpring(per-slab, independent)
* → useMotionTemplate → the `d` string of three <motion.path>s
*
* Nothing on that chain re-renders React. Move the cursor across the field and
* React DevTools stays silent; only prop changes (a new `falloff`, a new
* `spring`) cause a render.
*
* ## Why the path is a single scalar function
*
* Each slab is an isometric box drawn on two axes — A = (104, 52) "right-down"
* (the slab's length) and B = (-9, 4.5) "left-down" (its width). The solid is
* three faces, and every face is written so the height `h` appears **only** as
* `v{h}` down one side and `v{-h}` back up the other, wrapped around a fixed
* cap. The apex is placed at `anchorY - h`, so all three bottom corners land on
* `anchorY + AY + BY` regardless of `h`: the slab grows *upward* off a pinned
* bottom edge. That is what lets one number drive the entire solid.
*
* ## Pointer, not mouse
*
* Bound to `pointermove` / `pointerleave` / `pointercancel`, so it works under
* touch and pen, not just a mouse. `touch-action: pan-y` keeps vertical page
* scrolling intact while horizontal drags still drive the field.
*
* Client coordinates are converted through the SVG's own `getScreenCTM()`
* rather than a `getBoundingClientRect` ratio, so the mapping stays exact even
* when `preserveAspectRatio` letterboxes the drawing inside its box.
*
* "No pointer" is the sentinel `Infinity` — it fails `Number.isFinite`, and
* each slab falls back to its own resting height. No separate boolean, no
* state, and the resting silhouette is just the field at rest.
*/
/** Slab length axis, in viewBox units (isometric "right-down"). */
const AX = 104;
const AY = 52;
/** Slab width axis (isometric "left-down"). Negative X by construction. */
const BX = -9;
const BY = 4.5;
/** Distance is measured from the slab's own midpoint along the length axis. */
const CENTER_OFFSET = (AX + BX) / 2;
const VIEWBOX_WIDTH = 288;
const PAD = 4;
/**
* Default falloff: a two-stage ramp — a tight near lobe so the slab under the
* cursor spikes, then a long shallow tail so its neighbours read as a wake
* rather than a cliff. Distance is in viewBox units.
*/
function defaultFalloff(distance: number): number {
if (distance <= 12) return 1;
if (distance <= 48) return 1 - ((distance - 12) / 36) * 0.72;
if (distance <= 108) return 0.28 - ((distance - 48) / 60) * 0.28;
return 0;
}
/** The at-rest silhouette: a shallow descending ramp with one soft swell. */
function defaultRestingHeight(index: number, count: number, max: number) {
const t = count > 1 ? index / (count - 1) : 0;
return max * (0.14 + 0.07 * Math.sin(t * Math.PI * 1.8 + 0.6));
}
const clamp01 = (n: number) => (n < 0 ? 0 : n > 1 ? 1 : n);
export interface IsoMomentumProps extends Omit<
React.ComponentProps<"svg">,
"color"
> {
/** Number of slabs in the field. */
bars?: number;
/** Peak height of a slab directly under the pointer, in viewBox units. */
maxHeight?: number;
/**
* Distance (viewBox units) → intensity in 0…1, where 1 is "at the cursor".
* The slab's height is `lerp(restingHeight, maxHeight, falloff(distance))`.
* Swap this and the whole character of the field changes — that is the
* difference between a one-off illustration and a registry primitive.
*/
falloff?: (distance: number) => number;
/** Per-slab resting heights. Defaults to a generated ramp silhouette. */
restingHeights?: number[];
/**
* Spring applied to every slab independently. The perceived stagger is not
* scripted — it emerges because each slab starts from a different height.
*/
spring?: SpringOptions;
/** Fill for every face. Defaults to `currentColor`, so it inherits. */
color?: string;
/**
* Accessible name. Omit and the field is `aria-hidden` — it is decorative
* and has no state a screen reader or keyboard user could act on.
*/
label?: string;
}
export function IsoMomentum({
bars = 15,
maxHeight = 56,
falloff = defaultFalloff,
restingHeights,
spring = { stiffness: 140, damping: 18, mass: 1 },
color = "currentColor",
label,
className,
...props
}: IsoMomentumProps) {
const reduce = useReducedMotion();
const svgRef = React.useRef<SVGSVGElement>(null);
const count = Math.max(1, Math.round(bars));
// Lay the field out so it always fills the viewBox, whatever `bars` is.
const startX = VIEWBOX_WIDTH - PAD - AX;
const step = count > 1 ? (startX + BX - PAD) / (count - 1) : 0;
const startY = maxHeight + PAD;
const viewBoxHeight = startY + (step / 2) * (count - 1) + AY + BY + PAD;
const rest = React.useMemo(
() =>
Array.from(
{ length: count },
(_, i) =>
restingHeights?.[i] ?? defaultRestingHeight(i, count, maxHeight),
),
[count, maxHeight, restingHeights],
);
const minRest = React.useMemo(() => Math.min(...rest), [rest]);
// Infinity = "no pointer". Every slab reads it as "return to rest".
const pointerX = useMotionValue(Infinity);
const track = React.useCallback(
(e: React.PointerEvent<SVGSVGElement>) => {
const ctm = svgRef.current?.getScreenCTM();
if (!ctm) return;
// Screen → viewBox, exactly, whatever preserveAspectRatio did.
const local = new DOMPoint(e.clientX, e.clientY).matrixTransform(
ctm.inverse(),
);
pointerX.set(local.x);
},
[pointerX],
);
const release = React.useCallback(() => {
pointerX.set(Infinity);
}, [pointerX]);
const lastAnchorX = startX - step * (count - 1);
const lastAnchorY = startY + (step / 2) * (count - 1);
return (
<svg
ref={svgRef}
data-slot="iso-momentum"
viewBox={`0 0 ${VIEWBOX_WIDTH} ${viewBoxHeight}`}
onPointerMove={track}
onPointerDown={track}
onPointerLeave={release}
onPointerCancel={release}
role={label ? "img" : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
className={cn(
"touch-pan-y text-zinc-900 select-none dark:text-zinc-100",
className,
)}
{...props}
>
{/* The pinned baseline every slab grows off. Static by construction. */}
<path
d={`M${startX + AX + BX + 6} ${startY + AY + BY + 3}L${lastAnchorX + AX + BX - 6} ${lastAnchorY + AY + BY + 3}`}
stroke={color}
strokeOpacity={0.18}
strokeWidth={1}
fill="none"
/>
{rest.map((restHeight, i) => (
<Slab
key={i}
anchorX={startX - step * i}
anchorY={startY + (step / 2) * i}
pointerX={pointerX}
restHeight={restHeight}
minRest={minRest}
maxHeight={maxHeight}
falloff={falloff}
spring={spring}
color={color}
reduce={Boolean(reduce)}
/>
))}
</svg>
);
}
interface SlabProps {
anchorX: number;
anchorY: number;
pointerX: MotionValue<number>;
restHeight: number;
minRest: number;
maxHeight: number;
falloff: (distance: number) => number;
spring: SpringOptions;
color: string;
reduce: boolean;
}
/**
* One slab. Owns its own spring, so the field never moves as a unit — every
* slab chases its own target from its own starting height, and the stagger you
* see is emergent rather than scripted.
*/
function Slab({
anchorX,
anchorY,
pointerX,
restHeight,
minRest,
maxHeight,
falloff,
spring,
color,
reduce,
}: SlabProps) {
const centerX = anchorX + CENTER_OFFSET;
// distance → intensity → height. The Infinity sentinel lands here.
const target = useTransform(pointerX, (x) => {
if (reduce) return restHeight;
const distance = Math.abs(x - centerX);
if (!Number.isFinite(distance)) return restHeight;
return restHeight + (maxHeight - restHeight) * clamp01(falloff(distance));
});
const h = useSpring(target, spring);
const negH = useTransform(h, (v) => -v);
// Three faces, all driven by the one spring. The apex rises by `h`, and each
// face's `v{h}` / `v{-h}` pair puts the bottom edge back where it started.
const apexY = useTransform(h, (v) => anchorY - v);
const sideY = useTransform(h, (v) => anchorY - v + BY);
const capY = useTransform(h, (v) => anchorY - v + AY);
const dTop = useMotionTemplate`M${anchorX} ${apexY}l${AX} ${AY}l${BX} ${BY}l${-AX} ${-AY}z`;
const dSide = useMotionTemplate`M${anchorX + BX} ${sideY}l${AX} ${AY}v${h}l${-AX} ${-AY}v${negH}z`;
const dCap = useMotionTemplate`M${anchorX + AX} ${capY}l${BX} ${BY}v${h}l${-BX} ${-BY}v${negH}z`;
// A second read off the same spring: near the cursor a slab is solid, far
// away it recedes. Kept in one `opacity` on the group so nothing silently
// overrides anything else (the mistake the reference implementation makes,
// where an inline `filter` cancels the drop shadow it also declares).
const depth = useTransform(h, [minRest, maxHeight], [0.42, 1], {
clamp: true,
});
return (
<motion.g data-slot="iso-momentum-bar" style={{ opacity: depth }}>
<motion.path d={dSide} fill={color} fillOpacity={0.5} />
<motion.path d={dCap} fill={color} fillOpacity={0.72} />
<motion.path d={dTop} fill={color} fillOpacity={0.95} />
</motion.g>
);
}Installation
Terminal
npx shadcn@latest add https://ui.saumyarex.xyz/r/iso-momentum.json1. Install dependencies
Terminal
npm install motion clsx tailwind-merge2. Copy the source into your project
components/ui/iso-momentum.tsx
"use client";
import * as React from "react";
import {
motion,
useMotionTemplate,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
type MotionValue,
type SpringOptions,
} from "motion/react";
import { cn } from "@/lib/utils";
/**
* IsoMomentum — an isometric slab field whose heights track the pointer.
*
* The whole component is one continuous signal path with **no React state**:
*
* pointermove → useMotionValue(x, in viewBox units)
* → useTransform(distance → target height, via the `falloff` prop)
* → useSpring(per-slab, independent)
* → useMotionTemplate → the `d` string of three <motion.path>s
*
* Nothing on that chain re-renders React. Move the cursor across the field and
* React DevTools stays silent; only prop changes (a new `falloff`, a new
* `spring`) cause a render.
*
* ## Why the path is a single scalar function
*
* Each slab is an isometric box drawn on two axes — A = (104, 52) "right-down"
* (the slab's length) and B = (-9, 4.5) "left-down" (its width). The solid is
* three faces, and every face is written so the height `h` appears **only** as
* `v{h}` down one side and `v{-h}` back up the other, wrapped around a fixed
* cap. The apex is placed at `anchorY - h`, so all three bottom corners land on
* `anchorY + AY + BY` regardless of `h`: the slab grows *upward* off a pinned
* bottom edge. That is what lets one number drive the entire solid.
*
* ## Pointer, not mouse
*
* Bound to `pointermove` / `pointerleave` / `pointercancel`, so it works under
* touch and pen, not just a mouse. `touch-action: pan-y` keeps vertical page
* scrolling intact while horizontal drags still drive the field.
*
* Client coordinates are converted through the SVG's own `getScreenCTM()`
* rather than a `getBoundingClientRect` ratio, so the mapping stays exact even
* when `preserveAspectRatio` letterboxes the drawing inside its box.
*
* "No pointer" is the sentinel `Infinity` — it fails `Number.isFinite`, and
* each slab falls back to its own resting height. No separate boolean, no
* state, and the resting silhouette is just the field at rest.
*/
/** Slab length axis, in viewBox units (isometric "right-down"). */
const AX = 104;
const AY = 52;
/** Slab width axis (isometric "left-down"). Negative X by construction. */
const BX = -9;
const BY = 4.5;
/** Distance is measured from the slab's own midpoint along the length axis. */
const CENTER_OFFSET = (AX + BX) / 2;
const VIEWBOX_WIDTH = 288;
const PAD = 4;
/**
* Default falloff: a two-stage ramp — a tight near lobe so the slab under the
* cursor spikes, then a long shallow tail so its neighbours read as a wake
* rather than a cliff. Distance is in viewBox units.
*/
function defaultFalloff(distance: number): number {
if (distance <= 12) return 1;
if (distance <= 48) return 1 - ((distance - 12) / 36) * 0.72;
if (distance <= 108) return 0.28 - ((distance - 48) / 60) * 0.28;
return 0;
}
/** The at-rest silhouette: a shallow descending ramp with one soft swell. */
function defaultRestingHeight(index: number, count: number, max: number) {
const t = count > 1 ? index / (count - 1) : 0;
return max * (0.14 + 0.07 * Math.sin(t * Math.PI * 1.8 + 0.6));
}
const clamp01 = (n: number) => (n < 0 ? 0 : n > 1 ? 1 : n);
export interface IsoMomentumProps extends Omit<
React.ComponentProps<"svg">,
"color"
> {
/** Number of slabs in the field. */
bars?: number;
/** Peak height of a slab directly under the pointer, in viewBox units. */
maxHeight?: number;
/**
* Distance (viewBox units) → intensity in 0…1, where 1 is "at the cursor".
* The slab's height is `lerp(restingHeight, maxHeight, falloff(distance))`.
* Swap this and the whole character of the field changes — that is the
* difference between a one-off illustration and a registry primitive.
*/
falloff?: (distance: number) => number;
/** Per-slab resting heights. Defaults to a generated ramp silhouette. */
restingHeights?: number[];
/**
* Spring applied to every slab independently. The perceived stagger is not
* scripted — it emerges because each slab starts from a different height.
*/
spring?: SpringOptions;
/** Fill for every face. Defaults to `currentColor`, so it inherits. */
color?: string;
/**
* Accessible name. Omit and the field is `aria-hidden` — it is decorative
* and has no state a screen reader or keyboard user could act on.
*/
label?: string;
}
export function IsoMomentum({
bars = 15,
maxHeight = 56,
falloff = defaultFalloff,
restingHeights,
spring = { stiffness: 140, damping: 18, mass: 1 },
color = "currentColor",
label,
className,
...props
}: IsoMomentumProps) {
const reduce = useReducedMotion();
const svgRef = React.useRef<SVGSVGElement>(null);
const count = Math.max(1, Math.round(bars));
// Lay the field out so it always fills the viewBox, whatever `bars` is.
const startX = VIEWBOX_WIDTH - PAD - AX;
const step = count > 1 ? (startX + BX - PAD) / (count - 1) : 0;
const startY = maxHeight + PAD;
const viewBoxHeight = startY + (step / 2) * (count - 1) + AY + BY + PAD;
const rest = React.useMemo(
() =>
Array.from(
{ length: count },
(_, i) =>
restingHeights?.[i] ?? defaultRestingHeight(i, count, maxHeight),
),
[count, maxHeight, restingHeights],
);
const minRest = React.useMemo(() => Math.min(...rest), [rest]);
// Infinity = "no pointer". Every slab reads it as "return to rest".
const pointerX = useMotionValue(Infinity);
const track = React.useCallback(
(e: React.PointerEvent<SVGSVGElement>) => {
const ctm = svgRef.current?.getScreenCTM();
if (!ctm) return;
// Screen → viewBox, exactly, whatever preserveAspectRatio did.
const local = new DOMPoint(e.clientX, e.clientY).matrixTransform(
ctm.inverse(),
);
pointerX.set(local.x);
},
[pointerX],
);
const release = React.useCallback(() => {
pointerX.set(Infinity);
}, [pointerX]);
const lastAnchorX = startX - step * (count - 1);
const lastAnchorY = startY + (step / 2) * (count - 1);
return (
<svg
ref={svgRef}
data-slot="iso-momentum"
viewBox={`0 0 ${VIEWBOX_WIDTH} ${viewBoxHeight}`}
onPointerMove={track}
onPointerDown={track}
onPointerLeave={release}
onPointerCancel={release}
role={label ? "img" : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
className={cn(
"touch-pan-y text-zinc-900 select-none dark:text-zinc-100",
className,
)}
{...props}
>
{/* The pinned baseline every slab grows off. Static by construction. */}
<path
d={`M${startX + AX + BX + 6} ${startY + AY + BY + 3}L${lastAnchorX + AX + BX - 6} ${lastAnchorY + AY + BY + 3}`}
stroke={color}
strokeOpacity={0.18}
strokeWidth={1}
fill="none"
/>
{rest.map((restHeight, i) => (
<Slab
key={i}
anchorX={startX - step * i}
anchorY={startY + (step / 2) * i}
pointerX={pointerX}
restHeight={restHeight}
minRest={minRest}
maxHeight={maxHeight}
falloff={falloff}
spring={spring}
color={color}
reduce={Boolean(reduce)}
/>
))}
</svg>
);
}
interface SlabProps {
anchorX: number;
anchorY: number;
pointerX: MotionValue<number>;
restHeight: number;
minRest: number;
maxHeight: number;
falloff: (distance: number) => number;
spring: SpringOptions;
color: string;
reduce: boolean;
}
/**
* One slab. Owns its own spring, so the field never moves as a unit — every
* slab chases its own target from its own starting height, and the stagger you
* see is emergent rather than scripted.
*/
function Slab({
anchorX,
anchorY,
pointerX,
restHeight,
minRest,
maxHeight,
falloff,
spring,
color,
reduce,
}: SlabProps) {
const centerX = anchorX + CENTER_OFFSET;
// distance → intensity → height. The Infinity sentinel lands here.
const target = useTransform(pointerX, (x) => {
if (reduce) return restHeight;
const distance = Math.abs(x - centerX);
if (!Number.isFinite(distance)) return restHeight;
return restHeight + (maxHeight - restHeight) * clamp01(falloff(distance));
});
const h = useSpring(target, spring);
const negH = useTransform(h, (v) => -v);
// Three faces, all driven by the one spring. The apex rises by `h`, and each
// face's `v{h}` / `v{-h}` pair puts the bottom edge back where it started.
const apexY = useTransform(h, (v) => anchorY - v);
const sideY = useTransform(h, (v) => anchorY - v + BY);
const capY = useTransform(h, (v) => anchorY - v + AY);
const dTop = useMotionTemplate`M${anchorX} ${apexY}l${AX} ${AY}l${BX} ${BY}l${-AX} ${-AY}z`;
const dSide = useMotionTemplate`M${anchorX + BX} ${sideY}l${AX} ${AY}v${h}l${-AX} ${-AY}v${negH}z`;
const dCap = useMotionTemplate`M${anchorX + AX} ${capY}l${BX} ${BY}v${h}l${-BX} ${-BY}v${negH}z`;
// A second read off the same spring: near the cursor a slab is solid, far
// away it recedes. Kept in one `opacity` on the group so nothing silently
// overrides anything else (the mistake the reference implementation makes,
// where an inline `filter` cancels the drop shadow it also declares).
const depth = useTransform(h, [minRest, maxHeight], [0.42, 1], {
clamp: true,
});
return (
<motion.g data-slot="iso-momentum-bar" style={{ opacity: depth }}>
<motion.path d={dSide} fill={color} fillOpacity={0.5} />
<motion.path d={dCap} fill={color} fillOpacity={0.72} />
<motion.path d={dTop} fill={color} fillOpacity={0.95} />
</motion.g>
);
}lib/utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
/** Merge conditional class names and resolve Tailwind conflicts. */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Shared view-transition name so a card preview morphs into the detail
* page's preview. Must match on both ends; unique per registry entry. */
export function previewTransitionName(name: string) {
return `preview-${name}`;
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
bars | number | 15 | Number of slabs. The layout and viewBox height are derived from this, so the field always fills its box. |
maxHeight | number | 56 | Peak height of a slab directly under the pointer, in viewBox units. |
falloff | (distance: number) => number | — | Distance in viewBox units → intensity in 0…1. Height is `lerp(restingHeight, maxHeight, falloff(distance))`. Defaults to a two-stage ramp: a tight near lobe plus a long shallow tail. |
restingHeights | number[] | — | Per-slab heights when no pointer is present. Defaults to a generated ramp silhouette. |
spring | SpringOptions | { stiffness: 140, damping: 18, mass: 1 } | Applied to each slab independently. The stagger you see is emergent, not scripted — each slab springs from its own starting height. |
color | string | "currentColor" | Fill for every face, at three opacities. Inherits the text color by default, so it adapts to both themes for free. |
label | string | — | Accessible name. Omit and the field is `aria-hidden` — it is decorative and has no state to act on. |
Dependencies
motionclsxtailwind-merge