{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"text-block-effect","type":"registry:component","title":"Text block effect","description":"A coloured block sweeps each line in.","author":"DiMaac (HankTheTank) <https://dimaac.com>","dependencies":["gsap","@gsap/react"],"files":[{"path":"registry/dimaac/text-block-effect/text-block-effect.tsx","type":"registry:component","target":"components/dimaac/text-block-effect.tsx","content":"\"use client\";\n\nimport { createElement, useRef } from \"react\";\nimport { gsap } from \"gsap\";\nimport { ScrollTrigger } from \"gsap/ScrollTrigger\";\nimport { SplitText } from \"gsap/SplitText\";\nimport { useGSAP } from \"@gsap/react\";\n\n// Text that is revealed behind a coloured block. Each line gets a block that slides across it,\n// the words appear while the block covers them, and the block slides away to show the line.\n// The text stays real text: selectable, and read as one sentence by screen readers.\n// It starts by itself when it scrolls into view, so it does not need a section around it.\n\nif (typeof window !== \"undefined\") gsap.registerPlugin(useGSAP, ScrollTrigger, SplitText);\n\nexport type TextBlockEffectProps = {\n  /** The text to show. Plain text only (the lines are cut from it). */\n  children: string;\n  /** The tag to render: a page title, a section title or a paragraph. */\n  as?: \"h1\" | \"h2\" | \"h3\" | \"p\";\n  /** The colour of the sliding blocks. */\n  blockColor?: string;\n  /** The colour of the text. Empty keeps whatever colour the surrounding page gives it. */\n  textColor?: string;\n  /** The way the blocks travel across a line before they leave again. */\n  direction?: \"right\" | \"left\" | \"down\" | \"up\";\n  /** \"line\" reveals one line after another. \"cover\" covers every line first, then uncovers them all. */\n  mode?: \"line\" | \"cover\";\n  /** How long one block takes to slide across (or off) a line, in seconds. */\n  duration?: number;\n  /** The wait between one line starting and the next, in seconds. */\n  stagger?: number;\n  /** Play the reveal again each time the text scrolls back into view from above. */\n  replay?: boolean;\n  /** When the reveal starts: \"<element edge> <viewport edge>\", e.g. \"top 80%\". */\n  start?: string;\n  /** The scrolling element. Leave it out to use the page; give it a selector or an element for an inner scroller. */\n  scroller?: string | HTMLElement;\n  /** Extra classes: put the font size, weight, alignment and line height here. */\n  className?: string;\n};\n\nexport function TextBlockEffect({\n  children,\n  as = \"p\",\n  blockColor = \"#DDFC3E\",\n  textColor = \"\",\n  direction = \"right\",\n  mode = \"line\",\n  duration = 0.45,\n  stagger = 0.3,\n  replay = false,\n  start = \"top 80%\",\n  scroller,\n  className = \"\",\n}: TextBlockEffectProps) {\n  const textRef = useRef<HTMLElement>(null);\n\n  useGSAP(\n    () => {\n      const element = textRef.current;\n      if (!element) return;\n\n      // The text is hidden until it is split, so you never see it jump. Show it again either way.\n      element.style.visibility = \"visible\";\n\n      // Reduced motion: leave the text exactly as written, fully readable\n      if (window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches) return;\n\n      // Sideways directions use scaleX, up and down use scaleY.\n      // A block grows from the side it comes from, then shrinks towards the opposite side.\n      const sideways = direction === \"right\" || direction === \"left\";\n      const axis = sideways ? \"scaleX\" : \"scaleY\";\n      const origins = { right: [\"left center\", \"right center\"], left: [\"right center\", \"left center\"], down: [\"center top\", \"center bottom\"], up: [\"center bottom\", \"center top\"] } as const;\n      const [growFrom, shrinkTo] = origins[direction];\n\n      const split = SplitText.create(element, {\n        type: \"lines\",\n        linesClass: \"text-block-line\",\n        // Cut the lines again when the width changes or the web font arrives\n        autoSplit: true,\n        onSplit(self) {\n          const align = getComputedStyle(element).textAlign;\n          const pieces: { text: HTMLElement; block: HTMLElement }[] = [];\n\n          self.lines.forEach((line) => {\n            // The line shrinks to the width of its words, so the block is exactly as wide as the text\n            const lineBox = line as HTMLElement;\n            lineBox.style.display = \"block\";\n            lineBox.style.position = \"relative\";\n            lineBox.style.width = \"fit-content\";\n            lineBox.style.maxWidth = \"100%\";\n            lineBox.style.textAlign = \"start\";\n            if (align === \"center\") lineBox.style.marginInline = \"auto\";\n            else if (align === \"right\" || align === \"end\") lineBox.style.marginLeft = \"auto\";\n\n            // The words go in their own wrapper, so they can be hidden without hiding the block\n            const text = document.createElement(\"span\");\n            while (lineBox.firstChild) text.appendChild(lineBox.firstChild);\n            lineBox.appendChild(text);\n\n            // A touch bigger than the line, so no sliver of text shows around the edges\n            const block = document.createElement(\"span\");\n            block.setAttribute(\"aria-hidden\", \"true\");\n            block.style.cssText = `position:absolute;left:-1%;top:0;width:102%;height:102%;pointer-events:none;background:${blockColor};`;\n            lineBox.appendChild(block);\n\n            pieces.push({ text, block });\n          });\n\n          gsap.set(pieces.map((p) => p.text), { opacity: 0 });\n          gsap.set(pieces.map((p) => p.block), { [axis]: 0, transformOrigin: growFrom });\n\n          const timeline = gsap.timeline({\n            scrollTrigger: { trigger: element, scroller, start, toggleActions: replay ? \"play none none reset\" : \"play none none none\" },\n          });\n\n          const grow = (block: HTMLElement, at: number) => {\n            timeline.set(block, { transformOrigin: growFrom }, at);\n            timeline.to(block, { [axis]: 1, duration, ease: \"power2.inOut\" }, at);\n          };\n          const shrink = (block: HTMLElement, text: HTMLElement, at: number) => {\n            // The words are shown at the moment the block is fully over them\n            timeline.set(text, { opacity: 1 }, at);\n            timeline.set(block, { transformOrigin: shrinkTo }, at);\n            timeline.to(block, { [axis]: 0, duration, ease: \"power2.inOut\" }, at);\n          };\n\n          if (mode === \"cover\") {\n            // Every line is covered first, one after another, then they are all uncovered\n            pieces.forEach((piece, i) => grow(piece.block, i * stagger));\n            const covered = (pieces.length - 1) * stagger + duration;\n            pieces.forEach((piece, i) => shrink(piece.block, piece.text, covered + i * stagger));\n          } else {\n            // Each line is covered and uncovered in turn, the next one starting a moment later\n            pieces.forEach((piece, i) => {\n              grow(piece.block, i * stagger);\n              shrink(piece.block, piece.text, i * stagger + duration);\n            });\n          }\n\n          // Returning the timeline lets SplitText keep it in step when it re-splits\n          return timeline;\n        },\n      });\n\n      return () => split.revert();\n    },\n    { scope: textRef, dependencies: [children, as, blockColor, direction, mode, duration, stagger, replay, start, scroller], revertOnUpdate: true },\n  );\n\n  // The key makes React build a fresh element when the text or tag changes, because SplitText\n  // rewrites the inside of the old one.\n  return createElement(as, { ref: textRef, key: `${as}:${children}`, className, style: { visibility: \"hidden\", ...(textColor ? { color: textColor } : null) } }, children);\n}\n"}],"categories":["text","gsap","splittext","scrolltrigger","reveal","editorial"],"docs":"import { TextBlockEffect } from \"@/components/dimaac/text-block-effect\". Give its parent a size: it fills it. Docs, playground and props: https://ui.dimaac.com/components/text-block-effect","meta":{"tier":"free","preview":"https://ui.dimaac.com/components/text-block-effect","credits":[{"by":"stacy","url":"https://www.lummi.ai/photo/silhouette-with-sunglasses-wd3ut","what":"Background photo","source":"Lummi","licence":"Lummi License"}]}}