Skip to content
UI Kit
Accent color

Shimmer text

A light passes over the text. Pure CSS on the inherited color, paused off screen, still with reduced motion.

One of 5 components in Text accents

3 variants

Variants

Status line

The step that is working right now shimmers, the others rest.

Headline on scroll

One pass of accent light over a large line, carried by the scroll position. At rest the line stands in full color.

On hover

Text links rest in full color, one pass of light on hover and keyboard focus.

Details

What Shimmer text 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/shimmer-text.tsx
Brings along
utilskit
npm packages
None
Languages
Texts come through props
Studio
Adjust in code

Props

The ShimmerTextProps interface from components/ui/shimmer-text.tsx, with the comments from the code.

  • childrenrequired
    React.ReactNode

    The text. It stays real text, the light is only a background.

  • asoptional
    Tag

    h1h2h3h4pspandiv

    Element to render. Default span.

  • triggeroptional
    ShimmerTrigger

    loadviewin-viewhoverscroll

    view: the light passes again and again while the text is on screen (default). load: the same, also off screen. hover: one pass on every hover and focus of the text or of the link, button or .group around it; the text rests in full color. scroll: one pass of light that follows the scroll position, the text rests in full color.

  • scrollRangeoptional
    [number, number]

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

  • toneoptional
    'bright' | 'accent'

    bright: the light is the text color itself on a dimmed base (default). accent: the light takes the accent color.

  • durationoptional
    number

    Seconds for one pass including the rest before the next. Default 2.4.

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, 127 lines
components/ui/shimmer-text.tsx
'use client';

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

/** in-view is the same as view */
export type ShimmerTrigger = 'load' | 'view' | 'in-view' | 'hover' | 'scroll';
type Tag = 'h1' | 'h2' | 'h3' | 'h4' | 'p' | 'span' | 'div';

export interface ShimmerTextProps {
  /** The text. It stays real text, the light is only a background. */
  children: React.ReactNode;
  /** Element to render. Default span. */
  as?: Tag;
  /**
   * view: the light passes again and again while the text is on screen (default).
   * load: the same, also off screen. hover: one pass on every hover and focus
   * of the text or of the link, button or .group around it; the text rests in full color.
   * scroll: one pass of light that follows the scroll position, the text rests in full color.
   */
  trigger?: ShimmerTrigger;
  /** With trigger scroll: the part of the way through the window that carries the light, 0 to 1. Default [0.1, 0.6]. */
  scrollRange?: [number, number];
  /** bright: the light is the text color itself on a dimmed base (default). accent: the light takes the accent color. */
  tone?: 'bright' | 'accent';
  /** Seconds for one pass including the rest before the next. Default 2.4. */
  duration?: number;
  /** Classes for the element, e.g. text-h2 or text-sm text-muted-foreground */
  className?: string;
}

/*
 * The text keeps its inherited color property, only the glyph fill turns
 * transparent. So currentColor in the gradient is the inherited color,
 * and the effect works on any text color without knowing it.
 */
const CSS = `
.kitfx-sh{--kitfx-sh-band:currentColor;--kitfx-sh-base:color-mix(in oklch,currentColor 52%,transparent);background-image:linear-gradient(100deg,var(--kitfx-sh-base) 0 38%,var(--kitfx-sh-band) 50%,var(--kitfx-sh-base) 62% 100%);background-size:250% 100%;background-repeat:no-repeat;background-position:100% 0;-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;animation:kitfx-sh var(--kitfx-sh-dur) linear infinite}
.kitfx-sh[data-tone=accent]{--kitfx-sh-band:var(--primary);--kitfx-sh-base:color-mix(in oklch,currentColor 58%,transparent)}
.kitfx-sh[data-paused]{animation-play-state:paused}
.kitfx-sh[data-trigger=hover]{--kitfx-sh-base:currentColor;--kitfx-sh-band:color-mix(in oklch,currentColor 30%,var(--background));animation:none}
.kitfx-sh[data-trigger=hover][data-tone=accent]{--kitfx-sh-band:var(--primary)}
.kitfx-sh[data-trigger=hover]:is(:hover,:focus-visible,:focus-within),:is(a,button,.group):is(:hover,:focus-visible) .kitfx-sh[data-trigger=hover]{animation:kitfx-sh-once calc(var(--kitfx-sh-dur) * .5) cubic-bezier(.4,0,.2,1) both}
.kitfx-sh[data-trigger=scroll]{--kitfx-sh-base:currentColor;--kitfx-sh-band:color-mix(in oklch,currentColor 30%,var(--background));animation:none;background-position:calc((1 - var(--kitfx-p,1)) * 100%) 0;transition:background-position .25s linear}
.kitfx-sh[data-trigger=scroll][data-tone=accent]{--kitfx-sh-band:var(--primary)}
@keyframes kitfx-sh{0%{background-position:100% 0}65%,100%{background-position:0 0}}
@keyframes kitfx-sh-once{from{background-position:100% 0}to{background-position:0 0}}
@media (prefers-reduced-motion:reduce){.kitfx-sh,.kitfx-sh[data-trigger=hover]:is(:hover,:focus-visible,:focus-within),:is(a,button,.group):is(:hover,:focus-visible) .kitfx-sh[data-trigger=hover]{animation:none;background:none;-webkit-text-fill-color:currentColor}}`;

/**
 * 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 light passes over the text, like a status line that is working or a
 * badge that wants a second look. Pure CSS, it runs without JavaScript; the
 * script only pauses it while it is off screen. With reduced motion the
 * text stands still in its full color.
 *   <ShimmerText className="text-sm">Generating your site</ShimmerText>
 */
export function ShimmerText({ children, as: Tag = 'span', trigger = 'view', scrollRange = [0.1, 0.6], tone = 'bright', duration = 2.4, className }: ShimmerTextProps) {
  const ref = useRef<HTMLElement>(null);
  const mode = trigger === 'in-view' ? 'view' : trigger;
  useScrollLink(ref, mode === 'scroll', scrollRange);
  const [paused, setPaused] = useState(false);
  useEffect(() => {
    const el = ref.current;
    if (!el || mode !== 'view' || prefersReducedMotion()) return;
    const io = new IntersectionObserver(([e]) => setPaused(!e?.isIntersecting));
    io.observe(el);
    return () => io.disconnect();
  }, [mode]);
  return (
    <>
      <style dangerouslySetInnerHTML={{ __html: CSS }} />
      <Tag
        ref={ref as never}
        className={cn('kitfx-sh', className)}
        data-trigger={mode}
        data-tone={tone}
        data-paused={paused ? '' : undefined}
        style={{ '--kitfx-sh-dur': `${duration}s` } as React.CSSProperties}
      >
        {children}
      </Tag>
    </>
  );
}

Frequent questions

How do I install Shimmer text?

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

Which variants does Shimmer text have?

3 variants, Status line, Headline on scroll and On hover.

Can an AI assistant build with Shimmer text?

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

Often searched

Jump to

Copied to the clipboard