Skip to content
UI Kit
Accent color

Gradient sweep

A gradient built from the accent flows through the text. Pure CSS, paused off screen, still with reduced motion.

One of 5 components in Text accents

3 variants

Variants

Accent

The accent fades into a quiet grey and back, in a steady loop.

Spectrum on scroll

The accent and its two neighbours on the color wheel, moved by the scroll position instead of the clock.

Once

One sweep when the line scrolls in, then it rests. Ink palette, text color into the accent.

Details

What Gradient sweep brings along, read from the registry and the source.

Category
Text accents
Variants
3
Version
1.0.0 · since 1.0.0
File
components/ui/gradient-sweep.tsx
Brings along
utilskit
npm packages
None
Languages
Texts come through props
Studio
Adjust in code

Props

The GradientSweepProps interface from components/ui/gradient-sweep.tsx, with the comments from the code.

  • childrenrequired
    React.ReactNode

    The text. It stays real text, the gradient is only its fill.

  • asoptional
    Tag

    h1h2h3h4pspandiv

    Element to render. Default span, so it sits inside a heading.

  • paletteoptional
    GradientSweepPalette

    accentspectrumink

    accent: the accent fades into a quiet grey and back (default). spectrum: the accent and its two neighbours on the color wheel; a neutral accent turns into a quiet violet, rose and azure that keeps the text contrast in both modes. ink: the text color and the accent.

  • modeoptional
    GradientSweepMode

    looponce

    loop: flows on while on screen (default). once: one sweep when it scrolls in, then it rests.

  • triggeroptional
    GradientSweepTrigger

    loadviewin-viewscroll

    view (or in-view, default): a loop pauses off screen, a single sweep waits until the text scrolls in. load: a single sweep plays right away. scroll: the sweep follows the scroll position instead of the clock, one pass on the way through the window, back when you scroll up.

  • scrollRangeoptional
    [number, number]

    With trigger scroll: the part of the way through the window that carries the sweep, 0 to 1. Default [0.05, 0.6].

  • durationoptional
    number

    Seconds for one full cycle. Default 6.

Accessibility

Read from the source of the component.

  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • Moves with the scroll, its progress follows the position on the page.
  • Brings animations of its own, calmly timed and without wobble.
  • With reduced motion (prefers-reduced-motion) it stays still.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 156 lines
components/ui/gradient-sweep.tsx
'use client';

import { useEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion } from '@/lib/kit';

export type GradientSweepPalette = 'accent' | 'spectrum' | 'ink';
export type GradientSweepMode = 'loop' | 'once';
/** in-view is the same as view */
export type GradientSweepTrigger = 'load' | 'view' | 'in-view' | 'scroll';
type Tag = 'h1' | 'h2' | 'h3' | 'h4' | 'p' | 'span' | 'div';

export interface GradientSweepProps {
  /** The text. It stays real text, the gradient is only its fill. */
  children: React.ReactNode;
  /** Element to render. Default span, so it sits inside a heading. */
  as?: Tag;
  /**
   * accent: the accent fades into a quiet grey and back (default).
   * spectrum: the accent and its two neighbours on the color wheel; a
   * neutral accent turns into a quiet violet, rose and azure that keeps the
   * text contrast in both modes. ink: the text color and the accent.
   */
  palette?: GradientSweepPalette;
  /** loop: flows on while on screen (default). once: one sweep when it scrolls in, then it rests. */
  mode?: GradientSweepMode;
  /**
   * view (or in-view, default): a loop pauses off screen, a single sweep waits
   * until the text scrolls in. load: a single sweep plays right away.
   * scroll: the sweep follows the scroll position instead of the clock, one
   * pass on the way through the window, back when you scroll up.
   */
  trigger?: GradientSweepTrigger;
  /** With trigger scroll: the part of the way through the window that carries the sweep, 0 to 1. Default [0.05, 0.6]. */
  scrollRange?: [number, number];
  /** Seconds for one full cycle. Default 6. */
  duration?: number;
  /** Classes for the element, e.g. text-display or text-h1 */
  className?: string;
}

/*
 * The gradient repeats twice inside a background twice as wide as the text,
 * so shifting it by one text width is a seamless loop. Every color is a
 * token or derived from the accent. The spectrum hues need relative color
 * syntax and fall back to the accent palette where it is missing.
 */
const CSS = `
.kitfx-gs{--kitfx-gs-a:var(--primary);--kitfx-gs-b:color-mix(in oklch,var(--primary) 30%,var(--muted-foreground));--kitfx-gs-c:var(--kitfx-gs-a);background-image:linear-gradient(100deg,var(--kitfx-gs-a) 0%,var(--kitfx-gs-b) 12.5%,var(--kitfx-gs-c) 25%,var(--kitfx-gs-b) 37.5%,var(--kitfx-gs-a) 50%,var(--kitfx-gs-b) 62.5%,var(--kitfx-gs-c) 75%,var(--kitfx-gs-b) 87.5%,var(--kitfx-gs-a) 100%);background-size:200% 100%;background-position:0 0;-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;padding-block:.08em;margin-block:-.08em;box-decoration-break:clone;-webkit-box-decoration-break:clone}
.kitfx-gs[data-palette=ink]{--kitfx-gs-a:var(--foreground);--kitfx-gs-b:color-mix(in oklch,var(--primary) 70%,var(--foreground));--kitfx-gs-c:color-mix(in oklch,var(--foreground) 55%,var(--background))}
@supports (color:oklch(from red l c h)){
.kitfx-gs[data-palette=spectrum]{--kitfx-gs-n:clamp(0,1 - c / 0.06,1);--kitfx-gs-a:oklch(from var(--primary) calc(l + (clamp(0.44,(l - 0.5) * 100,0.82) - l) * var(--kitfx-gs-n)) max(c,0.12) h);--kitfx-gs-b:oklch(from var(--primary) calc(l + (clamp(0.44,(l - 0.5) * 100,0.82) - l) * var(--kitfx-gs-n)) max(c,0.12) calc(h + 48 + 12 * var(--kitfx-gs-n)));--kitfx-gs-c:oklch(from var(--primary) calc(l + (clamp(0.44,(l - 0.5) * 100,0.82) - l) * var(--kitfx-gs-n)) max(c,0.12) calc(h - 48 - 12 * var(--kitfx-gs-n)))}
}
.kitfx-gs[data-mode=loop]{animation:kitfx-gs-flow var(--kitfx-gs-dur) linear infinite}
.kitfx-gs[data-mode=loop][data-paused]{animation-play-state:paused}
.kitfx-gs[data-mode=once][data-state=play]{animation:kitfx-gs-once var(--kitfx-gs-dur) cubic-bezier(.25,.6,.2,1) both}
.kitfx-gs[data-mode=once][data-state=wait]{background-position:100% 0}
.kitfx-gs[data-mode=scroll]{background-position:calc((1 - var(--kitfx-p,1)) * 100%) 0;transition:background-position .25s linear}
@keyframes kitfx-gs-flow{to{background-position:-200% 0}}
@keyframes kitfx-gs-once{from{background-position:100% 0}}
@media (prefers-reduced-motion:reduce){.kitfx-gs[data-mode]{animation:none;background-position:0 0;transition:none}}`;

/**
 * Scroll link. Writes --kitfx-p (0 to 1) on the element: 0 when it has
 * travelled `start` of its way through the window, 1 at `end` (0 is its top
 * at the bottom edge, 1 its bottom at the top edge). The value is scaled to
 * the part of that way the page can still scroll, so a line at the end of a
 * short page still finishes. Never with reduced motion, then the CSS
 * fallback 1 keeps the final state.
 */
function useScrollLink(ref: { current: HTMLElement | null }, on: boolean, range: readonly [number, number], onChange?: (p: number) => void) {
  const [start, end] = range;
  const listener = useRef(onChange);
  listener.current = onChange;
  useEffect(() => {
    const el = ref.current;
    if (!el || !on || prefersReducedMotion()) return;
    let frame = 0;
    const span = Math.max(0.0001, end - start);
    const at = (top: number, h: number, vh: number) => Math.min(1, Math.max(0, ((vh - top) / (h + vh) - start) / span));
    const update = () => {
      frame = 0;
      const r = el.getBoundingClientRect();
      const vh = window.innerHeight;
      const rest = Math.max(0, document.documentElement.scrollHeight - vh - window.scrollY);
      const most = at(r.top - rest, r.height, vh);
      const p = most < 0.02 ? 1 : Math.min(1, at(r.top, r.height, vh) / most);
      el.style.setProperty('--kitfx-p', p.toFixed(4));
      listener.current?.(p);
    };
    const onScroll = () => {
      if (!frame) frame = requestAnimationFrame(update);
    };
    update();
    window.addEventListener('scroll', onScroll, { passive: true });
    window.addEventListener('resize', onScroll);
    return () => {
      cancelAnimationFrame(frame);
      window.removeEventListener('scroll', onScroll);
      window.removeEventListener('resize', onScroll);
      el.style.removeProperty('--kitfx-p');
    };
  }, [ref, on, start, end]);
}

/**
 * A gradient flows through the text, built from the accent color. Pure CSS,
 * it runs without JavaScript; the script pauses the loop off screen and holds
 * a one time sweep until the text scrolls in. Reduced motion shows the
 * gradient still.
 *   <h1 className="text-display">Websites that <GradientSweep>sell</GradientSweep></h1>
 */
export function GradientSweep({ children, as: Tag = 'span', palette = 'accent', mode = 'loop', trigger = 'view', scrollRange = [0.05, 0.6], duration = 6, className }: GradientSweepProps) {
  const ref = useRef<HTMLElement>(null);
  const scroll = trigger === 'scroll';
  useScrollLink(ref, scroll, scrollRange);
  const [paused, setPaused] = useState(false);
  const [state, setState] = useState<'play' | 'wait'>('play');
  useEffect(() => {
    const el = ref.current;
    if (!el || scroll || prefersReducedMotion()) return;
    if (mode === 'once') {
      if (trigger === 'load') return;
      if (el.getBoundingClientRect().top < window.innerHeight * 0.9) return;
      setState('wait');
    }
    const io = new IntersectionObserver(
      ([e]) => {
        const on = !!e?.isIntersecting;
        if (mode === 'loop') setPaused(!on);
        else if (on) {
          setState('play');
          io.disconnect();
        }
      },
      { rootMargin: mode === 'once' ? '0px 0px -10% 0px' : '0px' },
    );
    io.observe(el);
    return () => io.disconnect();
  }, [mode, scroll, trigger]);
  return (
    <>
      <style dangerouslySetInnerHTML={{ __html: CSS }} />
      <Tag
        ref={ref as never}
        className={cn('kitfx-gs', className)}
        data-palette={palette}
        data-mode={scroll ? 'scroll' : mode}
        data-state={state}
        data-paused={paused ? '' : undefined}
        style={{ '--kitfx-gs-dur': `${duration}s` } as React.CSSProperties}
      >
        {children}
      </Tag>
    </>
  );
}

Frequent questions

How do I install Gradient sweep?

With aiw add @ai-website/gradient-sweep. The command puts components/ui/gradient-sweep.tsx into your project, the code is yours from then on. It needs no further npm packages.

Which variants does Gradient sweep have?

3 variants, Accent, Spectrum on scroll and Once.

Can an AI assistant build with Gradient sweep?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Gradient sweep with aiw_components and install it with aiw_add @ai-website/gradient-sweep.

Often searched

Jump to

Copied to the clipboard