text3dFlip
A Text block/component from Magic UI — clean-room reimplemented for Domphy (see methodology). Call text3dFlip() with no arguments for a working demo, or edit the code below live.
Implementation notes
Implemented as pure CSS 3D transforms: each character is split into a front/back face pair (position wrapper + absolutely-stacked back face), both hinged around the configurable edge (top/bottom/left/right -> rotateX/rotateY + matching transform-origin) and revealed via a single &:hover [data-face=...] rule on the outer heading, with per-character transition-delay computed from staggerFrom (start/end/center/index) x staggerDelay to produce the wave-across-the-word effect. This faithfully covers the visual/DOM shape, edge choice, and stagger origin/order from the spec. The one real gap: the spec explicitly asks for spring-physics-driven rotation (a damped/bouncy settle, not a fixed duration+easing curve), and Domphy has no spring-animation primitive — motion() (the package's only animation patch) only drives Web Animations enter/exit/State keyframes with a fixed CSS easing string, not a continuously interactive hover-driven transform, and there is no JS spring library among this package's approved dependencies (cobe/canvas-confetti/rough-notation). Approximated with a hand-tuned CSS cubic-bezier(0.34, 1.56, 0.64, 1) 'back-out' overshoot curve on transition-timing-function, which reads as bouncy but is a fixed curve, not a true mass/stiffness/damping spring model — configurable via the easing prop if a different feel is wanted. Hover-only triggering (no click/keyboard-focus flip path) also means the reveal isn't reachable by keyboard users; the back face is marked aria-hidden and the front face carries the real accessible text, so this is a graceful-degradation choice rather than a broken state.
Status: partial · Reference: Magic UI original
// Magic UI "Text 3D Flip" — clean-room reimplementation.
//
// A line of heading-sized text where every character sits on its own 3D
// "card": a front face carrying the resting glyph and a back face carrying a
// second glyph/phrase, both hinged around the same edge (top/bottom/left/
// right). Hovering the whole line flips every character from front to back,
// staggered by a small per-character delay so the flip reads as a wave
// crossing the word rather than one simultaneous snap. Implemented purely
// from the block's public functional/visual spec — no upstream Magic UI
// source was viewed or copied.
//
// Pure CSS 3D transforms + `transition`, no JS animation loop: the flip is
// driven entirely by a `&:hover [data-face=...]` rule on the outer wrapper
// (no framer-motion, no per-character JS timers). A CSS cubic-bezier
// overshoot curve approximates the spring-like bounce the spec describes —
// Domphy's `motion()` patch only supports Web Animations enter/exit/State
// keyframes, not a continuously interactive hover-driven transform, so a
// hand-authored transition is the right tool here (same reasoning
// `scrollBasedVelocity.ts` uses for its own plain-CSS/rAF techniques).
import type { DomphyElement, StyleObject } from "@domphy/core";
import { heading } from "@domphy/ui";
import { type ThemeColor, themeColor, themeSpacing } from "@domphy/theme";
export type Text3dFlipEdge = "top" | "bottom" | "left" | "right";
export type Text3dFlipStaggerFrom = "start" | "end" | "center" | number;
export interface Text3dFlipProps {
/** Front-facing phrase. Defaults to a short demo quote. */
children?: string;
/** Phrase revealed on the flipped (back) face. Defaults to the same text as `children`, rendered
* in `flippedColor` — i.e. "the same text in a different style" per the spec's default variant. */
flippedChildren?: string;
/** Which edge each character hinges around. Defaults to `"top"`. */
edge?: Text3dFlipEdge;
/** Per-character stagger increment, in ms. Defaults to `50`. */
staggerDelay?: number;
/** Where the stagger wave originates: from the first character, the last, the center, or a
* specific character index (rippling outward from there). Defaults to `"start"`. */
staggerFrom?: Text3dFlipStaggerFrom;
/** How long each character's own flip takes, in ms. Defaults to `500`. */
duration?: number;
/** CSS easing for the flip. Defaults to a bouncy cubic-bezier overshoot approximating spring
* physics (moderate damping/stiffness — bouncy but controlled, not floppy). */
easing?: string;
/** Theme color role for the resting, front-facing text. Defaults to `"neutral"`. */
color?: ThemeColor;
/** Theme color role for the revealed, flipped text. Defaults to `"primary"`. */
flippedColor?: ThemeColor;
/** Passthrough style merged onto the outer wrapper. */
style?: StyleObject;
/** Passthrough style merged onto every front-facing character. */
frontStyle?: StyleObject;
/** Passthrough style merged onto every flipped (back) character. */
flippedStyle?: StyleObject;
}
const DEFAULT_TEXT = "Fortune favors the bold";
// A "back out" cubic-bezier — overshoots past the resting angle before
// settling, the closest a fixed CSS easing curve gets to a bouncy,
// moderately-damped spring without a JS physics loop.
const DEFAULT_SPRING_EASING = "cubic-bezier(0.34, 1.56, 0.64, 1)";
function staggerDistance(index: number, totalCharacters: number, from: Text3dFlipStaggerFrom): number {
if (from === "start") return index;
if (from === "end") return totalCharacters - 1 - index;
if (from === "center") return Math.abs(index - (totalCharacters - 1) / 2);
return Math.abs(index - from);
}
function transformOriginForEdge(edge: Text3dFlipEdge): string {
switch (edge) {
case "top":
return "center top";
case "bottom":
return "center bottom";
case "left":
return "left center";
case "right":
return "right center";
}
}
function rotationAxisForEdge(edge: Text3dFlipEdge): "X" | "Y" {
return edge === "left" || edge === "right" ? "Y" : "X";
}
interface CharacterCellOptions {
edge: Text3dFlipEdge;
duration: number;
easing: string;
staggerDelay: number;
staggerFrom: Text3dFlipStaggerFrom;
color: ThemeColor;
flippedColor: ThemeColor;
frontStyle?: StyleObject;
flippedStyle?: StyleObject;
}
/** Renders a single character's two-face flip cell. Space characters keep their layout width via a
* non-breaking space so the phrase's spacing survives being split into individually-styled cells. */
function characterCell(
frontCharacter: string,
flippedCharacter: string,
index: number,
totalCharacters: number,
options: CharacterCellOptions,
): DomphyElement<"span"> {
const delayMs = staggerDistance(index, totalCharacters, options.staggerFrom) * options.staggerDelay;
const axis = rotationAxisForEdge(options.edge);
const origin = transformOriginForEdge(options.edge);
const transition = `transform ${options.duration}ms ${options.easing} ${delayMs}ms`;
const glyph = (character: string) => (character === " " ? " " : character);
return {
span: [
{
span: glyph(frontCharacter),
dataFace: "front",
style: {
display: "block",
backfaceVisibility: "hidden",
transformOrigin: origin,
transform: `rotate${axis}(0deg)`,
transition,
color: (listener) => themeColor(listener, "shift-11", options.color),
...(options.frontStyle ?? {}),
} as StyleObject,
},
{
span: glyph(flippedCharacter),
dataFace: "back",
ariaHidden: "true",
style: {
display: "block",
position: "absolute",
inset: 0,
backfaceVisibility: "hidden",
transformOrigin: origin,
transform: `rotate${axis}(180deg)`,
transition,
color: (listener) => themeColor(listener, "shift-11", options.flippedColor),
...(options.flippedStyle ?? {}),
} as StyleObject,
},
],
_key: `char-${index}`,
style: {
position: "relative",
display: "inline-block",
transformStyle: "preserve-3d",
perspective: themeSpacing(160),
} as StyleObject,
};
}
/**
* A line of heading-sized text whose characters flip 90/180 degrees in 3D
* around a shared edge on hover, staggered into a wave across the word, to
* reveal a second phrase (or the same phrase restyled) underneath. Call with
* no arguments for a working demo — hover the phrase to see it flip.
*/
function text3dFlip(props: Text3dFlipProps = {}): DomphyElement<"h2"> {
const text = props.children ?? DEFAULT_TEXT;
const flippedText = props.flippedChildren ?? text;
const edge = props.edge ?? "top";
const staggerDelay = props.staggerDelay ?? 50;
const staggerFrom = props.staggerFrom ?? "start";
const duration = props.duration ?? 500;
const easing = props.easing ?? DEFAULT_SPRING_EASING;
const color = props.color ?? "neutral";
const flippedColor = props.flippedColor ?? "primary";
const frontCharacters = Array.from(text);
const flippedCharacters = Array.from(flippedText);
const totalCharacters = frontCharacters.length;
const cellOptions: CharacterCellOptions = {
edge,
duration,
easing,
staggerDelay,
staggerFrom,
color,
flippedColor,
frontStyle: props.frontStyle,
flippedStyle: props.flippedStyle,
};
const axis = rotationAxisForEdge(edge);
return {
h2: frontCharacters.map((character, index) =>
characterCell(
character,
flippedCharacters[index] ?? " ",
index,
totalCharacters,
cellOptions,
),
),
$: [heading({ color })],
style: {
fontStyle: "italic",
overflow: "hidden",
[`&:hover [data-face="front"]`]: {
transform: `rotate${axis}(-180deg)`,
},
[`&:hover [data-face="back"]`]: {
transform: `rotate${axis}(0deg)`,
},
...(props.style ?? {}),
} as StyleObject,
};
}
export { text3dFlip };