Text FXAdvanced Text Animationsno dependencies

CubeFlipWords

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 props
1.8
0.8
56

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add cube-flip-words

Writes 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.

example.tsx
1import { CubeFlipWords } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <CubeFlipWords />6  );7}

Props

PropTypeDefault
prefix

Static text shown before the rotating word.

string'Interfaces that feel'
words

Words cycled on the faces of the cube.

string[]['alive', 'fluid', 'inevitable', 'human']
interval

Pause between flips in seconds.

number1.8
duration

Duration of each flip in seconds.

number0.8
ease

GSAP-style ease name.

"power2.out" | "power3.out" | "power4.out" | "expo.out" | "back.out" | "elastic.out" | "sine.inOut" | "circ.out"'back.out'
direction

Direction the cube rolls: up brings the next word from below.

"up" | "down"'up'
accentColor

Color of the rotating word.

string'#a78bfa'
color

Prefix color (defaults to the theme foreground).

stringundefined
fontSize

Font size in pixels.

number56
as

Element rendered as the root.

"h1" | "h2" | "h3" | "h4" | "p" | "div"'h2'
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element (CSS variables included).

React.CSSPropertiesundefined

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

components/ui/cube-flip-words.tsx
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}