{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"settle-text","type":"registry:component","title":"Settle Text","description":"Headline rows that slide in while their letters drop in and settle.","author":"DiMaac (HankTheTank) <https://dimaac.com>","dependencies":["gsap","@gsap/react"],"files":[{"path":"registry/dimaac/settle-text/settle-text.tsx","type":"registry:component","target":"components/dimaac/settle-text.tsx","content":"\"use client\";\n\nimport { useRef } from \"react\";\nimport { gsap } from \"gsap\";\nimport { ScrollTrigger } from \"gsap/ScrollTrigger\";\nimport { SplitText } from \"gsap/SplitText\";\nimport { useGSAP } from \"@gsap/react\";\n\n// Big headline rows. As a row scrolls into view it slides in from the side while its letters\n// drop in from above and below (every other letter from the opposite side) and settle into the\n// word. The next row slides in from the other side, so the page zig-zags as you scroll.\n\nif (typeof window !== \"undefined\") gsap.registerPlugin(useGSAP, ScrollTrigger, SplitText);\n\nexport type SettleTextProps = {\n  /** The headlines, one per row. */\n  lines: string[];\n  /** Scroll smoothing in seconds: how long the text takes to catch up with the scrollbar. 0 follows it exactly. */\n  scrub?: number;\n  /** How far a row slides in from the side, as a percent of its width. 0 means no slide. */\n  offset?: number;\n  /** How much later each letter settles than the one before it, as a fraction of the row's scroll. */\n  letterStagger?: number;\n  /** How far, in pixels, the letters start above or below their place. */\n  drop?: number;\n  /** Slide every other row in from the opposite side, and settle its letters from the other end. */\n  alternate?: boolean;\n  /** The element that scrolls (an element or a CSS selector). The default is the window. */\n  scroller?: HTMLElement | string | null;\n  /** Extra classes for the outer box. */\n  className?: string;\n  /** Extra classes for every row. Sets the row height (85svh by default) and can set a background. */\n  rowClassName?: string;\n  /** Extra classes per row, repeating if there are more rows than entries. Handy for a background per row. */\n  rowClassNames?: string[];\n  /** Extra classes for the headline text, e.g. a font size or a font. */\n  headingClassName?: string;\n};\n\nexport function SettleText({\n  lines,\n  scrub = 1,\n  offset = 100,\n  letterStagger = 0.04,\n  drop = 150,\n  alternate = true,\n  scroller = null,\n  className = \"\",\n  rowClassName = \"h-[85svh]\",\n  rowClassNames = [],\n  headingClassName = \"text-[clamp(2.5rem,13vw,9rem)]\",\n}: SettleTextProps) {\n  const boxRef = useRef<HTMLDivElement>(null);\n\n  useGSAP(\n    () => {\n      const box = boxRef.current;\n      if (!box) return;\n\n      // Reduced motion: nothing is split or moved, the headlines just sit there, readable.\n      const mm = gsap.matchMedia();\n      mm.add(\"(prefers-reduced-motion: no-preference)\", () => {\n        const splits: SplitText[] = [];\n\n        gsap.utils.toArray<HTMLElement>(\"[data-settle-row]\", box).forEach((row, rowIndex) => {\n          const track = row.querySelector<HTMLElement>(\"[data-settle-track]\");\n          const heading = row.querySelector<HTMLElement>(\"[data-settle-heading]\");\n          if (!track || !heading) return;\n\n          // Each letter becomes its own inline-block, so it can move on its own.\n          // SplitText also keeps the whole word readable for screen readers.\n          const split = SplitText.create(heading, { type: \"chars\", charsClass: \"settle-char\" });\n          splits.push(split);\n          const letters = split.chars as HTMLElement[];\n          const count = letters.length;\n          if (count === 0) return;\n\n          // Odd rows go the other way: they slide in from the left and settle their last letter first.\n          const flipped = alternate && rowIndex % 2 === 1;\n          const side = flipped ? -1 : 1;\n\n          // The row's scroll is one timeline of length 1. The letters start at 0.1 and all\n          // finish by 1, so each letter's trip is whatever is left after the stagger.\n          // The stagger is capped so every letter still has a short trip.\n          const step = count > 1 ? Math.min(letterStagger, 0.7 / (count - 1)) : 0;\n          const trip = 0.9 - step * (count - 1);\n\n          const timeline = gsap.timeline({\n            defaults: { ease: \"none\" },\n            scrollTrigger: {\n              trigger: row,\n              ...(scroller ? { scroller } : {}),\n              start: \"top bottom\",\n              end: \"top top\",\n              scrub: scrub > 0 ? scrub : true,\n            },\n          });\n\n          timeline.fromTo(track, { xPercent: offset * side }, { xPercent: 0, duration: 1 }, 0);\n          letters.forEach((letter, index) => {\n            const order = flipped ? count - 1 - index : index;\n            const from = index % 2 === 0 ? -drop : drop;\n            timeline.fromTo(letter, { y: from }, { y: 0, duration: trip }, 0.1 + order * step);\n          });\n        });\n\n        return () => splits.forEach((split) => split.revert());\n      });\n\n      return () => mm.revert();\n    },\n    { scope: boxRef, dependencies: [lines.join(\"\\n\"), scrub, offset, letterStagger, drop, alternate, scroller], revertOnUpdate: true },\n  );\n\n  return (\n    <div ref={boxRef} className={`w-full ${className}`}>\n      {lines.map((line, index) => (\n        <div\n          key={`${index}-${line}`}\n          data-settle-row=\"\"\n          className={`flex w-full items-center overflow-hidden ${rowClassName} ${rowClassNames.length ? (rowClassNames[index % rowClassNames.length] ?? \"\") : \"\"}`}\n        >\n          <div data-settle-track=\"\" className=\"flex w-full justify-center will-change-transform\">\n            <h2 data-settle-heading=\"\" className={`m-0 font-extrabold leading-[0.92] tracking-[-0.03em] whitespace-nowrap ${headingClassName}`}>\n              {line}\n            </h2>\n          </div>\n        </div>\n      ))}\n    </div>\n  );\n}\n"}],"categories":["text","gsap","scrolltrigger","splittext","headline","scrub"],"docs":"import { SettleText } from \"@/components/dimaac/settle-text\". Give its parent a size: it fills it. Docs, playground and props: https://ui.dimaac.com/components/settle-text","meta":{"tier":"free","preview":"https://ui.dimaac.com/components/settle-text","credits":[]}}