Colour Band Sweep Text Reveal

A light hero whose headline finishes with a rotating phrase. Each phrase is revealed by a soft band of pink, red, amber and blue that sweeps from left to right: letters ahead of the band are invisible, letters inside it are coloured and letters behind it settle into dark ink. After a pause the next phrase is revealed the same way. Under the headline sit a paragraph and a search box with source chips. Everything is centred and the phrase wraps on phones.

Code

// Adapted from Magic UI (https://magicui.design/docs/components/dia-text-reveal)
// Copyright (c) Magic UI. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";
import { animate, motion, useInView, useMotionValue, useReducedMotion, useTransform } from "framer-motion";
import { ArrowUp, FileText } from "lucide-react";

const phrases = ["last quarter's numbers", "that contract clause", "the meeting you missed", "your travel receipts"];

const sources = ["Q3 board pack.pdf", "Supplier agreement v4", "Monday sync notes"];

const BAND = ["#c679c4", "#fa3d1d", "#ffb005", "#93c5fd", "#0358f7"];
const HALF = 17;
const START = -HALF;
const END = 100 + HALF;

const ease = (t: number) => (t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2);

/** Text colour behind the band, the band of colours at `pos` percent, nothing yet in front of it. */
function buildGradient(pos: number, colors: string[], textColor: string) {
  const a = pos - HALF;
  const b = pos + HALF;
  if (a >= 100) return `linear-gradient(90deg, ${textColor}, ${textColor})`;
  const parts: string[] = [];
  if (a > 0) parts.push(`${textColor} 0%`, `${textColor} ${a.toFixed(2)}%`);
  colors.forEach((c, i) => parts.push(`${c} ${(a + (i / (colors.length - 1)) * HALF * 2).toFixed(2)}%`));
  if (b < 100) parts.push(`transparent ${b.toFixed(2)}%`, "transparent 100%");
  return `linear-gradient(90deg, ${parts.join(", ")})`;
}

/**
 * Reveals text with a band of colour that sweeps from left to right and
 * leaves the plain text colour behind it. With several strings it moves on
 * to the next one after each sweep.
 */
function SweepReveal({ texts, colors = BAND, textColor = "#0f172a", duration = 1.5, pause = 1.3, className = "" }: { texts: string[]; colors?: string[]; textColor?: string; duration?: number; pause?: number; className?: string }) {
  const reduce = useReducedMotion();
  const ref = useRef<HTMLSpanElement>(null);
  const inView = useInView(ref, { amount: 0.1 });
  const [index, setIndex] = useState(0);
  const pos = useMotionValue(START);
  const backgroundImage = useTransform(pos, (p) => buildGradient(p, colors, textColor));

  useEffect(() => {
    if (reduce) {
      pos.set(END);
      return;
    }
    if (!inView) return;
    pos.set(START);
    let timer = 0;
    const controls = animate(pos, END, {
      duration,
      ease,
      onComplete() {
        if (texts.length > 1) timer = window.setTimeout(() => setIndex((i) => (i + 1) % texts.length), pause * 1000);
      },
    });
    return () => {
      controls.stop();
      window.clearTimeout(timer);
    };
  }, [index, inView, reduce, pos, duration, pause, texts.length]);

  return (
    <>
      <span className="sr-only">{texts[0]}</span>
      <motion.span ref={ref} aria-hidden="true" className={`inline-block bg-clip-text text-transparent ${className}`} style={{ backgroundImage, backgroundSize: "100% 100%", WebkitBackgroundClip: "text" }}>
        {texts[index]}
      </motion.span>
    </>
  );
}

export default function ColourBandSweepTextReveal() {
  return (
    <section className="relative overflow-hidden bg-white px-5 py-20 sm:px-6 sm:py-28">
      <span aria-hidden="true" className="pointer-events-none absolute -bottom-40 left-1/2 h-80 w-[900px] max-w-none -translate-x-1/2 rounded-full bg-gradient-to-r from-fuchsia-200 via-amber-100 to-blue-200 opacity-70 blur-3xl" />

      <div className="relative mx-auto max-w-4xl text-center">
        <p className="text-sm font-semibold uppercase tracking-widest text-slate-600">Margin, search for the work you already did</p>
        <h1 className="mt-6 text-4xl font-semibold tracking-tight text-slate-900 sm:text-6xl">
          Ask it about
          <span className="mt-1 block min-h-[2.4em] pb-2 sm:min-h-[1.25em]">
            <SweepReveal texts={phrases} />
          </span>
        </h1>
        <p className="mx-auto mt-4 max-w-xl text-lg leading-8 text-slate-600">Margin reads your documents, notes and inbox, then answers in a sentence and shows you where it looked.</p>

        <form onSubmit={(e) => e.preventDefault()} className="mx-auto mt-10 max-w-2xl rounded-3xl border border-slate-200 bg-white p-3 text-left shadow-xl shadow-slate-900/5">
          <div className="flex items-center gap-2">
            <label htmlFor="colour-band-sweep-text-reveal-q" className="sr-only">
              Your question
            </label>
            <input id="colour-band-sweep-text-reveal-q" type="text" defaultValue="What did we agree about late delivery?" className="h-12 min-w-0 flex-1 rounded-2xl bg-transparent px-3 text-base text-slate-900 placeholder:text-slate-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-600" />
            <button type="submit" aria-label="Ask" className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-slate-900 text-white transition hover:bg-blue-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2">
              <ArrowUp aria-hidden="true" className="h-5 w-5" />
            </button>
          </div>
          <ul className="mt-2 flex flex-wrap gap-2 border-t border-slate-100 px-1 pt-3">
            {sources.map((s) => (
              <li key={s} className="inline-flex items-center gap-1.5 rounded-full bg-slate-100 px-3 py-1.5 text-xs font-medium text-slate-700">
                <FileText aria-hidden="true" className="h-3.5 w-3.5" />
                {s}
              </li>
            ))}
          </ul>
        </form>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/colour-band-sweep-text-reveal.json

When to use it

How to customize it

Accessibility

Questions

How does the sweeping reveal work?

The text is transparent and shows a linear gradient through `background-clip: text`. The gradient has three zones: the ink colour, a band of bright colours and fully transparent. A single number, the band position, is animated from before the text to after it, and the gradient is rebuilt from that number on each frame.

Why does the text stay visible after the band has passed?

Once the band has moved off the right-hand side, the gradient is simply the ink colour from edge to edge, so the phrase reads as normal text until the next one starts.

Does it keep running off screen?

No. The sweep only starts while the phrase is in view, and the timer that advances to the next phrase is cleared when it leaves.

Can it reveal a whole paragraph?

It works on any inline block of text. For several lines the band crosses all of them at once, since the gradient is measured across the block's full width.

How do I stop the layout jumping between phrases?

Give the phrase its own line with a minimum height, as the demo does, so shorter and longer phrases occupy the same space.

Adapted from Dia Text Reveal in Magic UI, used under its open-source licence. Licences and credits

Related: Rotating Word Headline with Spring Flip, Typewriter Headline with Cycling Phrases, Word by Word Blur In Paragraph Reveal.

More text effects or all free UI components. Start for free to have a full site built around it.

More in Text Effects

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap