Subframe
Animation & motion3D text roll

A fresh turn of phrase

Two faces of each letter turn together in a compact dimensional reveal.

Replay the letter rotation

Source: Motion Primitives

Add to your project

Component source

Standalone React component. Styles are included.

npm install [email protected]

Render the default export inside a container with a defined height. Its optional active prop stops movement when false. This example works with React 19; Next.js projects can use it as a client component.

View source code
/**
 * Adapted from https://github.com/ibelick/motion-primitives/blob/120f64f6ca60348e251f929e9c81f11ccbe45eda/components/core/text-roll.tsx
 * Standalone composition; inline styles and artwork; bounded motion and reduced-motion behavior.
 *
 * MIT License
 *
 * Copyright (c) 2024 ibelick
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in all
 * copies or substantial portions of the Software.
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
 * SOFTWARE.
 */

"use client"

import { motion } from "motion/react"
import { useEffect, useState } from "react"

// Read after hydration; subscribe so an OS preference change updates this instance.
function useReducedMotion() {
  const [reduced, setReduced] = useState<boolean | null>(null)
  useEffect(() => {
    const media = window.matchMedia("(prefers-reduced-motion: reduce)")
    const update = () => setReduced(media.matches)
    update()
    media.addEventListener("change", update)
    return () => media.removeEventListener("change", update)
  }, [])
  return reduced
}

const text = "A new perspective."

export default function TextRoll({ active = true }: { active?: boolean }) {
  const reduced = useReducedMotion()
  const canAnimate = active && reduced === false
  return (
    <div
      style={{
        width: "100%",
        height: "100%",
        minHeight: 260,
        display: "flex",
        flexDirection: "column",
        justifyContent: "center",
        alignItems: "center",
        gap: 18,
        background: "#dedde7",
        color: "#555167",
        fontFamily: "inherit",
        padding: 20,
        boxSizing: "border-box",
      }}
    >
      <span aria-hidden="true" style={{ color: "#928da2", fontSize: 35, fontWeight: 200 }}>
        ↳
      </span>
      <h3
        key={canAnimate ? "play" : "still"}
        aria-label={text}
        style={{
          margin: 0,
          fontSize: "clamp(22px, 4vw, 33px)",
          letterSpacing: "-.045em",
          fontWeight: 450,
          lineHeight: 1.3,
          whiteSpace: "nowrap",
        }}
      >
        {Array.from(text, (letter, index) => (
          <span
            key={index}
            aria-hidden="true"
            style={{ position: "relative", display: "inline-block", perspective: 10000, transformStyle: "preserve-3d" }}
          >
            <motion.span
              style={{
                position: "absolute",
                display: "inline-block",
                backfaceVisibility: "hidden",
                transformOrigin: "50% 25%",
              }}
              initial={canAnimate ? { rotateX: 0 } : false}
              animate={{ rotateX: canAnimate ? 90 : 0 }}
              transition={{
                duration: canAnimate ? 0.32 : 0,
                delay: canAnimate ? index * 0.025 : 0,
                ease: [0.25, 1, 0.5, 1],
              }}
            >
              {letter === " " ? "\u00a0" : letter}
            </motion.span>
            {canAnimate && (
              <motion.span
                style={{
                  position: "absolute",
                  display: "inline-block",
                  backfaceVisibility: "hidden",
                  transformOrigin: "50% 100%",
                }}
                initial={{ rotateX: 90 }}
                animate={{ rotateX: 0 }}
                transition={{ duration: 0.36, delay: index * 0.025 + 0.12, ease: [0.25, 1, 0.5, 1] }}
              >
                {letter === " " ? "\u00a0" : letter}
              </motion.span>
            )}
            <span style={{ visibility: "hidden" }}>{letter === " " ? "\u00a0" : letter}</span>
          </span>
        ))}
      </h3>
      <span style={{ color: "#5f5c6a", fontSize: 10 }}>The same words. A different way in.</span>
    </div>
  )
}

Where it works well

Give a short launch statement a single moment of emphasis.

Reduced motion

The complete phrase is shown immediately without turning or stagger.

Things to know

The demo is a single short line. Longer text needs wrapping and locale-specific character segmentation.

More examples