# Springy Text (`springy-text`)

> Words fall into place with individual spring physics on scroll enter, drop away downward on exit.

- **Docs:** https://www.mellowui.com/components/springy-text
- **Markdown:** https://www.mellowui.com/components/springy-text.md
- **Registry:** https://www.mellowui.com/r/springy-text.json
- **Tool prompt:** https://www.mellowui.com/api/prompt/springy-text
- **Categories:** text, scroll, animation
- **Dependencies:** motion

## AI prompt

Add a SpringyText component from the mellow library. Each letter in the text prop springs into place on scroll enter with staggered physics, reacts to cursor approach direction, and exits with directional stagger based on scroll direction. Key props: text, as, staggerDelay, enterFrom, exitTo, once, threshold.

## Install

```bash
npx shadcn@latest add https://mellowui.com/r/springy-text.json
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `text` | `string` | — | The text to animate. |
| `as` | `"h1" \| "h2" \| "h3" \| "h4" \| "p"` | `"h2"` | Element type used for the wrapper. |
| `wordClassName` | `string` | — | Class name applied to each word wrapper. |
| `letterClassName` | `string` | — | Class name applied to each letter wrapper. |
| `staggerDelay` | `number` | `15` | Stagger delay between letters in milliseconds. |
| `enterFrom` | `"top" \| "bottom"` | `"top"` | Direction letters enter from. |
| `exitTo` | `"bottom" \| "top"` | `"bottom"` | Direction letters exit to. |
| `once` | `boolean` | `false` | If true, entrance animation only runs once. |
| `threshold` | `number` | `0.3` | Viewport threshold for triggering in-view animation. |
| `className` | `string` | — | Additional CSS classes applied to the wrapper element. |

## Design notes

- Client component (`"use client"`) — required for animation and refs.
- Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors.
- Respects `prefers-reduced-motion: reduce` where applicable.
- Self-contained — no shared utilities. Copy-paste, not a package.

## Source

```tsx
"use client";

import React, { useRef } from "react";
import { motion, useInView, useReducedMotion } from "motion/react";

export interface SpringyTextProps {
  text: string;
  as?: "h1" | "h2" | "h3" | "h4" | "p";
  className?: string;
  wordClassName?: string;
  letterClassName?: string;
  staggerDelay?: number;
  enterFrom?: "top" | "bottom";
  exitTo?: "bottom" | "top";
  once?: boolean;
  threshold?: number;
}

export function SpringyText({ as: Tag = "h2", ...rest }: SpringyTextProps) {
  // Keyed on Tag so switching the wrapper element (e.g. h2 -> h1) fully
  // remounts this subtree instead of swapping the host element type under a
  // persistent ref. Without this, `useInView`'s IntersectionObserver stays
  // bound to the old (now-detached) node and `inView` gets stuck permanently
  // false, leaving every letter invisible.
  return <SpringyTextBody key={Tag} as={Tag} {...rest} />;
}

function SpringyTextBody({
  text,
  as: Tag = "h2",
  className,
  wordClassName,
  letterClassName,
  staggerDelay = 15,
  enterFrom = "top",
  exitTo = "bottom",
  once = false,
  threshold = 0.3,
}: SpringyTextProps) {
  const containerRef = useRef<HTMLElement>(null);
  const reduced = useReducedMotion();

  const inView = useInView(containerRef as React.RefObject<Element>, {
    amount: threshold,
    once,
  });

  const scrollDir = useRef<"down" | "up">("down");

  React.useEffect(() => {
    let lastScrollY = window.scrollY;
    let ticking = false;

    const updateScrollDir = () => {
      const scrollY = window.scrollY;
      if (Math.abs(scrollY - lastScrollY) > 2) {
        scrollDir.current = scrollY > lastScrollY ? "down" : "up";
        lastScrollY = scrollY > 0 ? scrollY : 0;
      }
      ticking = false;
    };

    const onScroll = () => {
      if (!ticking) {
        window.requestAnimationFrame(updateScrollDir);
        ticking = true;
      }
    };

    window.addEventListener("scroll", onScroll, { passive: true });
    return () => window.removeEventListener("scroll", onScroll);
  }, []);

  const words = text.split(" ");
  let charIndex = 0;
  const wordsWithChars = words.map((word) => {
    return word.split("").map((char) => {
      return { char, index: charIndex++ };
    });
  });
  const totalChars = charIndex;

  const enterY = enterFrom === "top" ? -80 : 80;
  const exitY = exitTo === "bottom" ? 80 : -80;

  return React.createElement(
    Tag as React.ElementType,
    {
      ref: containerRef,
      className,
      "aria-label": text,
    },
    wordsWithChars.map((wordChars, wordIdx) => (
      <React.Fragment key={wordIdx}>
        <WordCluster
          wordChars={wordChars}
          wordClassName={wordClassName}
          letterClassName={letterClassName}
          reduced={reduced}
          inView={inView}
          enterY={enterY}
          exitY={exitY}
          scrollDirection={scrollDir.current}
          totalChars={totalChars}
          staggerDelay={staggerDelay}
        />
        {wordIdx < wordsWithChars.length - 1 && " "}
      </React.Fragment>
    ))
  );
}

function WordCluster({
  wordChars,
  wordClassName,
  letterClassName,
  reduced,
  inView,
  enterY,
  exitY,
  scrollDirection,
  totalChars,
  staggerDelay,
}: {
  wordChars: Array<{ char: string; index: number }>;
  wordClassName?: string;
  letterClassName?: string;
  reduced: boolean | null;
  inView: boolean;
  enterY: number;
  exitY: number;
  scrollDirection: "down" | "up";
  totalChars: number;
  staggerDelay: number;
}) {
  const [activeLetterIndex, setActiveLetterIndex] = React.useState<number | null>(null);
  const [hoverOffsetPx, setHoverOffsetPx] = React.useState(0);

  return (
    <span className={wordClassName} style={{ display: "inline-block", whiteSpace: "nowrap" }}>
      {wordChars.map(({ char, index }, localIndex) => {
        const stiffness = 200 + (index % 3) * 40;
        const damping = index % 4 === 0 ? 13 : 10 + (index % 4) * 2;
        const mass = 0.6 + (index % 5) * 0.08;

        const isForward = scrollDirection === "down";
        const staggerIndex = isForward ? index : totalChars - index - 1;

        const delay = inView
          ? (staggerIndex * staggerDelay) / 1000
          : (staggerIndex * staggerDelay * 0.5) / 1000;

        return (
          <Letter
            key={index}
            char={char}
            localIndex={localIndex}
            activeIndex={activeLetterIndex}
            hoverOffsetPx={hoverOffsetPx}
            setActiveIndex={setActiveLetterIndex}
            setHoverOffsetPx={setHoverOffsetPx}
            letterClassName={letterClassName}
            reduced={reduced}
            inView={inView}
            enterY={enterY}
            exitY={exitY}
            stiffness={stiffness}
            damping={damping}
            mass={mass}
            baseDelay={delay}
          />
        );
      })}
    </span>
  );
}

function Letter({
  char,
  localIndex,
  activeIndex,
  hoverOffsetPx,
  setActiveIndex,
  setHoverOffsetPx,
  letterClassName,
  reduced,
  inView,
  enterY,
  exitY,
  stiffness,
  damping,
  mass,
  baseDelay,
}: {
  char: string;
  localIndex: number;
  activeIndex: number | null;
  hoverOffsetPx: number;
  setActiveIndex: React.Dispatch<React.SetStateAction<number | null>>;
  setHoverOffsetPx: React.Dispatch<React.SetStateAction<number>>;
  letterClassName?: string;
  reduced: boolean | null;
  inView: boolean;
  enterY: number;
  exitY: number;
  stiffness: number;
  damping: number;
  mass: number;
  baseDelay: number;
}) {
  const [activeDelay, setActiveDelay] = React.useState(baseDelay);

  React.useEffect(() => {
    setActiveDelay(baseDelay);
  }, [baseDelay]);

  const handlePointerEnter = (e: React.PointerEvent<HTMLSpanElement>) => {
    if (reduced || !inView) return;
    setActiveDelay(0);
    const rect = e.currentTarget.getBoundingClientRect();
    const centerY = rect.top + rect.height / 2;
    const offset = e.clientY < centerY ? rect.height * 0.28 : -rect.height * 0.28;
    setActiveIndex(localIndex);
    setHoverOffsetPx(offset);
  };

  const handlePointerLeave = () => {
    if (reduced || !inView) return;
    setActiveDelay(0);
    setActiveIndex(null);
    setHoverOffsetPx(0);
  };

  let interactiveOffset = 0;
  if (activeIndex === localIndex) {
    interactiveOffset = hoverOffsetPx;
  } else if (activeIndex !== null && Math.abs(activeIndex - localIndex) === 1) {
    interactiveOffset = hoverOffsetPx / 2;
  } else if (activeIndex !== null && Math.abs(activeIndex - localIndex) === 2) {
    interactiveOffset = hoverOffsetPx / 4;
  } else if (activeIndex !== null && Math.abs(activeIndex - localIndex) === 3) {
    interactiveOffset = hoverOffsetPx / 8;
  }

  return (
    <span
      className={letterClassName}
      style={{
        display: "inline-block",
        overflowY: "hidden",
        overflowX: "visible",
        verticalAlign: "bottom",
        padding: "0.5em max(0.2em, 2px)",
        margin: "-0.5em calc(max(0.2em, 2px) * -1)",
      }}
      aria-hidden={true}
      onPointerEnter={handlePointerEnter}
      onPointerLeave={handlePointerLeave}
    >
      <motion.span
        style={{
          display: "inline-block",
          willChange: "transform, opacity, filter",
        }}
        initial={{
          y: reduced ? 0 : enterY,
          opacity: 0,
          filter: reduced ? "blur(0px)" : "blur(4px)",
        }}
        animate={{
          y: reduced ? 0 : inView ? interactiveOffset : exitY,
          opacity: inView ? 1 : 0,
          filter: reduced ? "blur(0px)" : inView ? "blur(0px)" : "blur(4px)",
        }}
        transition={
          reduced
            ? { duration: 0.3, delay: activeDelay }
            : {
                type: "spring",
                stiffness,
                damping,
                mass,
                delay: activeDelay,
              }
        }
      >
        {char}
      </motion.span>
    </span>
  );
}

export default SpringyText;

```

## Demo

```tsx
"use client";

import React from "react";
import { SpringyText } from "../mellow/springy-text";

export default function SpringyTextDemo() {
  return (
    <div className="flex flex-col items-center justify-center gap-16 p-12 text-center min-h-[600px]">
      <SpringyText
        text="Letters spring away from your cursor."
        as="h2"
        className="text-5xl font-medium tracking-tight text-var(--ink) font-sans"
        enterFrom="top"
        exitTo="bottom"
      />
      <SpringyText
        text="Each ripple settles back with soft spring physics."
        as="p"
        staggerDelay={12}
        className="text-base text-[rgba(var(--ink-rgb),0.55)] font-sans"
        enterFrom="bottom"
        exitTo="top"
      />
      <p className="mt-4 text-[0.7rem] text-[rgba(var(--ink-rgb),0.3)] font-mono uppercase tracking-[0.18em]">
        Scroll to see enter and exit animations
      </p>
    </div>
  );
}

```
