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
Details
What Gradient sweep brings along, read from the registry and the source.
- Category
- Text accents
- Area
- Elements
- 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
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The GradientSweepProps interface from components/ui/gradient-sweep.tsx, with the comments from the code.
childrenrequiredReact.ReactNodeThe text. It stays real text, the gradient is only its fill.
asoptionalTagh1h2h3h4pspandiv
Element to render. Default span, so it sits inside a heading.
paletteoptionalGradientSweepPaletteaccentspectrumink
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.
modeoptionalGradientSweepModelooponce
loop: flows on while on screen (default). once: one sweep when it scrolls in, then it rests.
triggeroptionalGradientSweepTriggerloadviewin-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].
durationoptionalnumberSeconds 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
'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.