Headline with a static prefix followed by a word that rotates through a list on a true 3D cube flip (rotateX with perspective, the next word on the adjacent face), reserving the widest word's width so the line never jumps.
Preview
Interfaces that feel alive, fluid, inevitable, human
Customize
8 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add cube-flip-wordsWrites components/ui/cube-flip-words.tsx and components/styles/cube-flip-words.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { CubeFlipWords } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <CubeFlipWords />6 );7}Props
Accessibility
Screen readers get a single stable visually hidden sentence listing the prefix and all words, so nothing is announced on every flip; the cube is aria-hidden. Cycling pauses when the element is off-screen or the tab is hidden. Under prefers-reduced-motion the first word is shown statically with no cycling.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import styles from "../styles/cube-flip-words.module.css";5 6export type CubeFlipWordsEase =7 "power2.out" | "power3.out" | "power4.out" | "expo.out" | "back.out" | "elastic.out" | "sine.inOut" | "circ.out";8 9export interface CubeFlipWordsProps {10 /** Static text shown before the rotating word */11 prefix?: string;12 /** Words cycled on the faces of the cube */13 words?: string[];14 /** Pause between flips in seconds */15 interval?: number;16 /** Duration of each flip in seconds */17 duration?: number;18 /** GSAP-style ease name */19 ease?: CubeFlipWordsEase;20 /** Direction the cube rolls: "up" brings the next word from below */21 direction?: "up" | "down";22 /** Color of the rotating word */23 accentColor?: string;24 /** Prefix color (defaults to the theme foreground) */25 color?: string;26 /** Font size in pixels */27 fontSize?: number;28 /** Element rendered as the root */29 as?: "h1" | "h2" | "h3" | "h4" | "p" | "div";30 /** Optional className merged onto the root */31 className?: string;32 /** Optional inline styles merged onto the root */33 style?: React.CSSProperties;34}35 36const EASES: Record<CubeFlipWordsEase, (t: number) => number> = {37 "power2.out": (t) => 1 - Math.pow(1 - t, 3),38 "power3.out": (t) => 1 - Math.pow(1 - t, 4),39 "power4.out": (t) => 1 - Math.pow(1 - t, 5),40 "expo.out": (t) => (t >= 1 ? 1 : 1 - Math.pow(2, -10 * t)),41 "back.out": (t) => {42 const c = 1.70158;43 const u = t - 1;44 return 1 + (c + 1) * u * u * u + c * u * u;45 },46 "elastic.out": (t) =>47 t <= 0 ? 0 : t >= 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * ((2 * Math.PI) / 3)) + 1,48 "sine.inOut": (t) => -(Math.cos(Math.PI * t) - 1) / 2,49 "circ.out": (t) => Math.sqrt(1 - Math.pow(t - 1, 2)),50};51 52const useIsoLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;53 54const DEFAULT_WORDS = ["alive", "fluid", "inevitable", "human"];55 56const DEPTH = "0.6em";57 58export function CubeFlipWords({59 prefix = "Interfaces that feel",60 words = DEFAULT_WORDS,61 interval = 1.8,62 duration = 0.8,63 ease = "back.out",64 direction = "up",65 accentColor = "#a78bfa",66 color,67 fontSize = 56,68 as = "h2",69 className = "",70 style,71}: CubeFlipWordsProps) {72 const list = React.useMemo(() => {73 const clean = words.map((w) => w.trim()).filter(Boolean);74 return clean.length ? clean : ["motion"];75 }, [words]);76 const listKey = list.join("\u0000");77 const [index, setIndex] = React.useState(0);78 const rootRef = React.useRef<HTMLElement | null>(null);79 const cubeRef = React.useRef<HTMLSpanElement | null>(null);80 const frontRef = React.useRef<HTMLSpanElement | null>(null);81 const nextRef = React.useRef<HTMLSpanElement | null>(null);82 const sign = direction === "down" ? -1 : 1;83 84 const pose = React.useCallback(85 (p: number) => {86 const cube = cubeRef.current;87 if (!cube) return;88 cube.style.transform = `translateZ(-${DEPTH}) rotateX(${(sign * 90 * p).toFixed(3)}deg)`;89 const q = Math.min(1, Math.max(0, p));90 if (frontRef.current) frontRef.current.style.opacity = (1 - 0.75 * q).toFixed(3);91 if (nextRef.current) nextRef.current.style.opacity = (0.25 + 0.75 * q).toFixed(3);92 },93 [sign],94 );95 96 // After each committed word swap, snap the cube back to its resting pose before paint.97 useIsoLayoutEffect(() => {98 pose(0);99 }, [index, pose, listKey]);100 101 React.useEffect(() => {102 setIndex(0);103 const root = rootRef.current;104 if (!root || list.length < 2) return;105 if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;106 107 const easeFn = EASES[ease] ?? EASES["back.out"];108 const dur = Math.max(0.05, duration) * 1000;109 let raf = 0;110 let timer: ReturnType<typeof setTimeout> | null = null;111 let inView = true;112 let waiting = false;113 114 const canRun = () => inView && !document.hidden;115 116 const flip = () => {117 timer = null;118 if (!canRun()) {119 waiting = true;120 return;121 }122 const start = performance.now();123 const tick = (now: number) => {124 const t = Math.min(1, (now - start) / dur);125 pose(t >= 1 ? 1 : easeFn(t));126 if (t < 1) {127 raf = requestAnimationFrame(tick);128 } else {129 raf = 0;130 setIndex((i) => (i + 1) % list.length);131 schedule();132 }133 };134 raf = requestAnimationFrame(tick);135 };136 137 const schedule = () => {138 timer = setTimeout(flip, Math.max(0.2, interval) * 1000);139 };140 141 const resume = () => {142 if (waiting && canRun()) {143 waiting = false;144 schedule();145 }146 };147 148 let io: IntersectionObserver | null = null;149 if (typeof IntersectionObserver !== "undefined") {150 io = new IntersectionObserver((entries) => {151 inView = entries.some((e) => e.isIntersecting);152 resume();153 });154 io.observe(root);155 }156 document.addEventListener("visibilitychange", resume);157 schedule();158 159 return () => {160 if (timer) clearTimeout(timer);161 if (raf) cancelAnimationFrame(raf);162 io?.disconnect();163 document.removeEventListener("visibilitychange", resume);164 };165 }, [listKey, interval, duration, ease, pose, list.length]);166 167 const current = list[index % list.length];168 const upcoming = list[(index + 1) % list.length];169 const Tag = as;170 const vars = {171 "--mf-cf-size": `${fontSize}px`,172 "--mf-cf-accent": accentColor,173 "--mf-cf-depth": DEPTH,174 ...(color ? { "--mf-cf-color": color } : {}),175 ...style,176 } as React.CSSProperties;177 178 return (179 <Tag180 ref={(el: HTMLElement | null) => {181 rootRef.current = el;182 }}183 className={`${styles.root} ${className}`.trim()}184 data-direction={direction}185 style={vars}186 >187 <span className={styles.srOnly}>{`${prefix} ${list.join(", ")}`}</span>188 <span aria-hidden="true" className={styles.visual}>189 {prefix ? <span className={styles.prefix}>{prefix} </span> : null}190 <span className={styles.slot}>191 {list.map((w, i) => (192 <span key={`${w}-${i}`} className={styles.sizer}>193 {w}194 </span>195 ))}196 <span ref={cubeRef} className={styles.cube}>197 <span ref={frontRef} className={`${styles.face} ${styles.front}`}>198 {current}199 </span>200 <span ref={nextRef} className={`${styles.face} ${styles.next}`}>201 {upcoming}202 </span>203 </span>204 </span>205 </span>206 </Tag>207 );208}