wobbleCard
A Cards block/component from Aceternity UI — clean-room reimplemented for Domphy (see methodology). Call wobbleCard() with no arguments for a working demo, or edit the code below live.
Implementation notes
Full behavior match: mousemove writes a damped (6% of raw offset, capped at 18px) translate + 1.03 scale directly to the content layer's inline transform, easing back to rest on mouseleave via a static CSS transition. Grain overlay is a tiled feTurbulence SVG data-URI used as a CSS background-image (not a real SVG filter element tree, since Domphy's SvgTags allowlist doesn't yet namespace feTurbulence/feColorMatrix — same gap noiseTexture.ts documents) rather than a canvas draw loop, kept independent to avoid duplicating noiseTexture.ts's canvas logic; toggleable via the noise prop.
Status: ported · Reference: Aceternity UI original
// Aceternity UI "Wobble Card" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). A large
// feature tile whose content layer translates a few pixels toward the
// pointer (plus a very slight scale bump) as the cursor moves across the
// card, reading as a soft parallax "wobble" rather than a full drag.
//
// Position tracking is a plain `mousemove` listener writing straight to the
// content layer's inline `transform` — the same "no animation curve needed,
// the browser repaints every pointer-move" tradeoff magicCard.ts makes for
// its cursor-following glow — with a short CSS transition covering the
// mouseleave ease-back-to-rest.
//
// The grain overlay is a tiled `feTurbulence` SVG encoded as a CSS
// `background-image` data URI (a well-known, generic CSS technique — not
// sourced from any of the disallowed references) rather than a real SVG
// filter chain in the element tree: Domphy's `SvgTags` allowlist doesn't
// namespace `feTurbulence`/`feColorMatrix` yet (see noiseTexture.ts's fidelity
// note for the full explanation), but a data-URI background image is just an
// opaque image resource to the browser and sidesteps that gap entirely.
import type { DomphyElement, ElementNode, StyleObject } from "@domphy/core";
import { heading, paragraph } from "@domphy/ui";
import { type ThemeColor, themeColor, themeSpacing } from "@domphy/theme";
export interface WobbleCardProps {
/** Card body — heading, description, optional image. Defaults to a generic demo blurb. */
children?: DomphyElement[];
/** Theme color family for the card's background/text ramp. Defaults to `"primary"`. */
color?: ThemeColor;
/** Renders the decorative grain overlay. Defaults to `true`. */
noise?: boolean;
/** Passthrough style merged onto the outer card container. */
style?: StyleObject;
/** Passthrough style merged onto the inner content wrapper that receives the wobble transform. */
contentStyle?: StyleObject;
}
// Damping keeps the translate to a few percent of the raw pointer offset —
// subtle parallax, not a drag. Capped so a huge card never produces a huge shift.
const POINTER_DAMPING = 0.06;
const MAX_TRANSLATE_PX = 18;
const HOVER_SCALE = 1.03;
const GRAIN_TEXTURE_MARKUP =
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120">' +
'<filter id="wobble-grain"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="3" stitchTiles="stitch"/>' +
'<feColorMatrix type="saturate" values="0"/></filter>' +
'<rect width="100%" height="100%" filter="url(#wobble-grain)"/></svg>';
const GRAIN_TEXTURE_DATA_URI = `data:image/svg+xml,${encodeURIComponent(GRAIN_TEXTURE_MARKUP)}`;
function defaultChildren(): DomphyElement[] {
return [
{ h3: "Ship products people love", $: [heading({ color: "neutral" })] } as DomphyElement,
{
p: "A bento tile that leans gently toward your cursor — a small, tactile detail that makes the surface feel alive.",
$: [paragraph({ color: "neutral" })],
} as DomphyElement,
];
}
/** Decorative tiled grain layer — purely visual, no text of its own. */
function grainOverlay(): DomphyElement<"div"> {
const element = {
div: null,
ariaHidden: "true",
_doctorDisable: "missing-color",
style: {
position: "absolute",
inset: 0,
pointerEvents: "none",
backgroundImage: `url("${GRAIN_TEXTURE_DATA_URI}")`,
backgroundRepeat: "repeat",
backgroundSize: "128px 128px",
mixBlendMode: "overlay",
opacity: 0.25,
},
};
return element as DomphyElement<"div">;
}
/**
* A large feature tile whose content subtly translates and scales toward the
* cursor as it moves across the card. Call with no arguments for a working
* demo — a colored tile with a generic heading/description.
*/
function wobbleCard(props: WobbleCardProps = {}): DomphyElement<"div"> {
const color = props.color ?? "primary";
const noise = props.noise ?? true;
const children = props.children ?? defaultChildren();
let contentElement: HTMLElement | null = null;
const contentWrapper: DomphyElement<"div"> = {
div: children,
_onMount: (node: ElementNode) => {
contentElement = node.domElement as HTMLElement;
},
_onRemove: () => {
contentElement = null;
},
style: {
position: "relative",
zIndex: 1,
display: "flex",
flexDirection: "column",
gap: themeSpacing(2),
maxWidth: themeSpacing(90),
transition: "transform 250ms cubic-bezier(0.22, 1, 0.36, 1)",
transform: "translate(0, 0) scale(1)",
willChange: "transform",
...(props.contentStyle ?? {}),
} as StyleObject,
};
return {
div: [contentWrapper, ...(noise ? [grainOverlay()] : [])],
dataTone: "shift-16",
onMouseMove: (event: MouseEvent, node: ElementNode) => {
if (!contentElement) return;
const rect = (node.domElement as HTMLElement).getBoundingClientRect();
const offsetX = event.clientX - (rect.left + rect.width / 2);
const offsetY = event.clientY - (rect.top + rect.height / 2);
const translateX = Math.max(-MAX_TRANSLATE_PX, Math.min(MAX_TRANSLATE_PX, offsetX * POINTER_DAMPING));
const translateY = Math.max(-MAX_TRANSLATE_PX, Math.min(MAX_TRANSLATE_PX, offsetY * POINTER_DAMPING));
contentElement.style.transform = `translate(${translateX.toFixed(2)}px, ${translateY.toFixed(2)}px) scale(${HOVER_SCALE})`;
},
onMouseLeave: () => {
if (contentElement) contentElement.style.transform = "translate(0, 0) scale(1)";
},
style: {
position: "relative",
overflow: "hidden",
borderRadius: themeSpacing(5),
padding: themeSpacing(8),
minHeight: themeSpacing(48),
backgroundColor: (listener) => themeColor(listener, "inherit", color),
color: (listener) => themeColor(listener, "shift-9", color),
...(props.style ?? {}),
} as StyleObject,
};
}
export { wobbleCard };