---
title: Settle Text
description: Headline rows that slide in while their letters drop in and settle.
category: Text
tier: free
url: https://ui.dimaac.com/components/settle-text
registry: https://ui.dimaac.com/r/settle-text.json
---

# Settle Text

Big headline rows driven by the scroll. As a row climbs into view it slides in from the side, and its letters fall in from above and below (every other letter from the opposite side) and settle one after another into the word. The next row comes in from the other side, so the page zig-zags as you scroll. Built with GSAP ScrollTrigger and SplitText, scrubbed so the motion follows the scrollbar with a little smoothing. It takes an optional scroller element, reverts the split text on unmount, keeps the headline readable for screen readers, and leaves the text still when the visitor prefers reduced motion. Recreated from the Scroll text YouTube tutorial.

## Install

With the shadcn CLI:

```bash
npx shadcn@latest add https://ui.dimaac.com/r/settle-text.json
pnpm dlx shadcn@latest add https://ui.dimaac.com/r/settle-text.json
bunx --bun shadcn@latest add https://ui.dimaac.com/r/settle-text.json
```

With the `@dimaac` namespace in components.json: `npx shadcn@latest add @dimaac/settle-text`

Dependencies:

```bash
pnpm add gsap @gsap/react
npm install gsap @gsap/react
```

By hand:

1. Install the packages it uses: gsap, @gsap/react.
2. Copy the component file below into components/dimaac/settle-text.tsx.
3. It's styled with Tailwind v4 classes. If that folder sits outside the places Tailwind scans, add it with @source in your CSS.
4. Import it with import { SettleText } from "@/components/dimaac/settle-text" and give its parent a size: it fills it.

## Usage

```tsx
import { SettleText } from "@/components/dimaac/settle-text";

<SettleText />
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `lines` (required) | `string[]` | - | The headlines, one per row. |
| `scrub` | `number` | `1` | Scroll smoothing in seconds: how long the text takes to catch up with the scrollbar. 0 follows it exactly. |
| `offset` | `number` | `100` | How far a row slides in from the side, as a percent of its width. 0 means no slide. |
| `letterStagger` | `number` | `0.04` | How much later each letter settles than the one before it, as a fraction of the row's scroll. |
| `drop` | `number` | `150` | How far, in pixels, the letters start above or below their place. |
| `alternate` | `boolean` | `true` | Slide every other row in from the opposite side, and settle its letters from the other end. |
| `scroller` | `HTMLElement \| string \| null` | `null` | The element that scrolls (an element or a CSS selector). The default is the window. |
| `className` | `string` | `""` | Extra classes for the outer box. |
| `rowClassName` | `string` | `"h-[85svh]"` | Extra classes for every row. Sets the row height (85svh by default) and can set a background. |
| `rowClassNames` | `string[]` | `[]` | Extra classes per row, repeating if there are more rows than entries. Handy for a background per row. |
| `headingClassName` | `string` | `"text-[clamp(2.5rem,13vw,9rem)]"` | Extra classes for the headline text, e.g. a font size or a font. |

## Source

### settle-text.tsx → `components/dimaac/settle-text.tsx`

```tsx
"use client";

import { useRef } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";
import { useGSAP } from "@gsap/react";

// Big headline rows. As a row scrolls into view it slides in from the side while its letters
// drop in from above and below (every other letter from the opposite side) and settle into the
// word. The next row slides in from the other side, so the page zig-zags as you scroll.

if (typeof window !== "undefined") gsap.registerPlugin(useGSAP, ScrollTrigger, SplitText);

export type SettleTextProps = {
  /** The headlines, one per row. */
  lines: string[];
  /** Scroll smoothing in seconds: how long the text takes to catch up with the scrollbar. 0 follows it exactly. */
  scrub?: number;
  /** How far a row slides in from the side, as a percent of its width. 0 means no slide. */
  offset?: number;
  /** How much later each letter settles than the one before it, as a fraction of the row's scroll. */
  letterStagger?: number;
  /** How far, in pixels, the letters start above or below their place. */
  drop?: number;
  /** Slide every other row in from the opposite side, and settle its letters from the other end. */
  alternate?: boolean;
  /** The element that scrolls (an element or a CSS selector). The default is the window. */
  scroller?: HTMLElement | string | null;
  /** Extra classes for the outer box. */
  className?: string;
  /** Extra classes for every row. Sets the row height (85svh by default) and can set a background. */
  rowClassName?: string;
  /** Extra classes per row, repeating if there are more rows than entries. Handy for a background per row. */
  rowClassNames?: string[];
  /** Extra classes for the headline text, e.g. a font size or a font. */
  headingClassName?: string;
};

export function SettleText({
  lines,
  scrub = 1,
  offset = 100,
  letterStagger = 0.04,
  drop = 150,
  alternate = true,
  scroller = null,
  className = "",
  rowClassName = "h-[85svh]",
  rowClassNames = [],
  headingClassName = "text-[clamp(2.5rem,13vw,9rem)]",
}: SettleTextProps) {
  const boxRef = useRef<HTMLDivElement>(null);

  useGSAP(
    () => {
      const box = boxRef.current;
      if (!box) return;

      // Reduced motion: nothing is split or moved, the headlines just sit there, readable.
      const mm = gsap.matchMedia();
      mm.add("(prefers-reduced-motion: no-preference)", () => {
        const splits: SplitText[] = [];

        gsap.utils.toArray<HTMLElement>("[data-settle-row]", box).forEach((row, rowIndex) => {
          const track = row.querySelector<HTMLElement>("[data-settle-track]");
          const heading = row.querySelector<HTMLElement>("[data-settle-heading]");
          if (!track || !heading) return;

          // Each letter becomes its own inline-block, so it can move on its own.
          // SplitText also keeps the whole word readable for screen readers.
          const split = SplitText.create(heading, { type: "chars", charsClass: "settle-char" });
          splits.push(split);
          const letters = split.chars as HTMLElement[];
          const count = letters.length;
          if (count === 0) return;

          // Odd rows go the other way: they slide in from the left and settle their last letter first.
          const flipped = alternate && rowIndex % 2 === 1;
          const side = flipped ? -1 : 1;

          // The row's scroll is one timeline of length 1. The letters start at 0.1 and all
          // finish by 1, so each letter's trip is whatever is left after the stagger.
          // The stagger is capped so every letter still has a short trip.
          const step = count > 1 ? Math.min(letterStagger, 0.7 / (count - 1)) : 0;
          const trip = 0.9 - step * (count - 1);

          const timeline = gsap.timeline({
            defaults: { ease: "none" },
            scrollTrigger: {
              trigger: row,
              ...(scroller ? { scroller } : {}),
              start: "top bottom",
              end: "top top",
              scrub: scrub > 0 ? scrub : true,
            },
          });

          timeline.fromTo(track, { xPercent: offset * side }, { xPercent: 0, duration: 1 }, 0);
          letters.forEach((letter, index) => {
            const order = flipped ? count - 1 - index : index;
            const from = index % 2 === 0 ? -drop : drop;
            timeline.fromTo(letter, { y: from }, { y: 0, duration: trip }, 0.1 + order * step);
          });
        });

        return () => splits.forEach((split) => split.revert());
      });

      return () => mm.revert();
    },
    { scope: boxRef, dependencies: [lines.join("\n"), scrub, offset, letterStagger, drop, alternate, scroller], revertOnUpdate: true },
  );

  return (
    <div ref={boxRef} className={`w-full ${className}`}>
      {lines.map((line, index) => (
        <div
          key={`${index}-${line}`}
          data-settle-row=""
          className={`flex w-full items-center overflow-hidden ${rowClassName} ${rowClassNames.length ? (rowClassNames[index % rowClassNames.length] ?? "") : ""}`}
        >
          <div data-settle-track="" className="flex w-full justify-center will-change-transform">
            <h2 data-settle-heading="" className={`m-0 font-extrabold leading-[0.92] tracking-[-0.03em] whitespace-nowrap ${headingClassName}`}>
              {line}
            </h2>
          </div>
        </div>
      ))}
    </div>
  );
}
```

### demo.tsx (the demo on the page, not installed)

```tsx
"use client";

import { SettleText, type SettleTextProps } from "@/components/dimaac/settle-text";

// The scroll page from the video: a dark intro, three coloured headline rows, a quiet end.
// The demo has its own colours, so it looks the same in the light and the dark site theme.
// The playground sends "lines" as one string, with " / " between the rows.

const rowColours = [
  "bg-[linear-gradient(135deg,#3b6947_0%,#0f1a12_100%)] text-[#ede8df]",
  "bg-[#ede8df] text-[#0a0a0c]",
  "bg-[linear-gradient(135deg,#721e30_0%,#1a0a0e_100%)] text-[#ede8df]",
];

const defaultLines = ["Pull Into Focus", "Fade Into Static", "Burst Into Bloom"];

type DemoProps = Omit<Partial<SettleTextProps>, "lines"> & { lines?: string | string[] };

function toLines(value: DemoProps["lines"]): string[] {
  const list = Array.isArray(value) ? value : typeof value === "string" ? value.split("/") : defaultLines;
  const clean = list.map((line) => line.trim()).filter(Boolean);
  return clean.length ? clean : defaultLines;
}

export default function Demo({ lines, ...props }: DemoProps) {
  return (
    <div className="w-full bg-[#0a0a0c] [font-family:Bricolage_Grotesque,sans-serif] text-[#f4f0e8]">
      <link
        rel="stylesheet"
        href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,800&display=swap"
        precedence="default"
      />

      <section className="grid h-svh place-content-center px-8 text-center">
        <p className="mb-6 text-xs font-medium tracking-[0.2em] text-[#c9a84c] uppercase">Scroll to explore</p>
        <h1 className="bg-gradient-to-b from-[#f4f0e8] to-[#8a8680] bg-clip-text text-[clamp(3rem,13vw,8rem)] leading-[0.92] font-extrabold tracking-[-0.03em] whitespace-nowrap text-transparent">
          Just Scroll
        </h1>
      </section>

      <SettleText lines={toLines(lines)} rowClassNames={rowColours} {...props} />

      <section className="grid h-svh place-content-center bg-gradient-to-b from-[#2a2a2f] to-[#0a0a0c] px-8 text-center">
        <p className="bg-gradient-to-b from-[#f4f0e8] to-[#8a8680] bg-clip-text text-[clamp(3rem,13vw,8rem)] leading-[0.92] font-extrabold tracking-[-0.03em] whitespace-nowrap text-transparent">
          The End
        </p>
      </section>
    </div>
  );
}
```

## Agent prompt

```text
Add the Settle Text component (<SettleText />) from DiMaac UI to this project.

Docs: https://ui.dimaac.com/components/settle-text
Source and dependencies (shadcn registry JSON): https://ui.dimaac.com/r/settle-text.json

Install it with:
npx shadcn@latest add https://ui.dimaac.com/r/settle-text.json

Then use it like this:
import { SettleText } from "@/components/dimaac/settle-text";
<SettleText />

If the shadcn CLI isn't set up here, fetch the registry JSON above, write files[0].content to components/dimaac/settle-text.tsx and install gsap, @gsap/react.
It uses Tailwind v4 classes, so make sure Tailwind scans that folder. Give its parent a size, keep light and dark mode working and keep its prefers-reduced-motion fallback.
Full index for agents: https://ui.dimaac.com/llms.txt
```
