Skip to content
UI Kit
Accent color

Grid fade

A line or tile grid that fades out, with cells that light up in the accent color.

One of 6 components in Backgrounds

3 variants

Variants

Lines with pulse

Hairlines through the middle settle into place as the stage scrolls up, a few cells pulse slowly.

Tiles

Rounded tiles with small gaps, highlighted ones in the accent.

Dashed from the top

A quiet dashed grid like drafting paper that fades downward.

Details

What Grid fade brings along, read from the registry and the source.

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

Props

The GridFadeProps interface from components/ui/grid-fade.tsx, with the comments from the code.

  • variantoptional
    GridFadeVariant

    linessquaresdashed

    lines: hairline grid. squares: rounded tiles with gaps. dashed: a quiet dashed grid.

  • celloptional
    number

    Cell size in px, default 48

  • fadeoptional
    GridFadeFade

    radialtopbottomnone

    Where the grid stays visible: radial around the middle, toward the top or the bottom

  • highlightsoptional
    number

    How many cells light up in the accent color, default 0

  • pulseoptional
    boolean

    Let the highlighted cells pulse slowly, default true

  • seedoptional
    number

    Same seed, same cells

  • scrollRevealoptional
    boolean

    The grid settles from a little larger and fainter into place while the stage scrolls up to the middle of the window. Default false.

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, 152 lines
components/ui/grid-fade.tsx
'use client';

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

export type GridFadeVariant = 'lines' | 'squares' | 'dashed';
export type GridFadeFade = 'radial' | 'top' | 'bottom' | 'none';

export interface GridFadeProps {
  /** lines: hairline grid. squares: rounded tiles with gaps. dashed: a quiet dashed grid. */
  variant?: GridFadeVariant;
  /** Cell size in px, default 48 */
  cell?: number;
  /** Where the grid stays visible: radial around the middle, toward the top or the bottom */
  fade?: GridFadeFade;
  /** How many cells light up in the accent color, default 0 */
  highlights?: number;
  /** Let the highlighted cells pulse slowly, default true */
  pulse?: boolean;
  /** Same seed, same cells */
  seed?: number;
  /** The grid settles from a little larger and fainter into place while the stage scrolls up to the middle of the window. Default false. */
  scrollReveal?: boolean;
  className?: string;
}

const CSS = `
@keyframes kitfx-grid-pulse { 0%, 100% { opacity: 0.3; } 50% { opacity: 0.9; } }
.kitfx-grid * { animation-play-state: var(--kitfx-play, running); }
.kitfx-grid[data-scroll] > svg { --e: min(1, var(--kitfx-p, 0.5) * 2); opacity: calc(0.3 + 0.7 * var(--e)); transform: scale(calc(1.06 - 0.06 * var(--e))); transform-origin: 50% 45%; transition: opacity .3s linear, transform .3s linear; }
@media (prefers-reduced-motion: reduce) { .kitfx-grid * { animation: none !important; } .kitfx-grid[data-scroll] > svg { opacity: 1; transform: none; } }
`;

const FADE: Record<GridFadeFade, string | undefined> = {
  radial: 'radial-gradient(ellipse 70% 65% at 50% 45%, #000 25%, transparent 78%)',
  top: 'linear-gradient(to bottom, #000 10%, transparent 75%)',
  bottom: 'linear-gradient(to top, #000 10%, transparent 75%)',
  none: undefined,
};

function seeded(seed: number) {
  let a = seed >>> 0 || 1;
  return () => {
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

/**
 * Scroll pass. While `on`, writes --kitfx-p on the element: 0 when its top
 * meets the bottom of the window, 1 when its bottom leaves at the top. Never
 * with reduced motion, the CSS then falls back to the resting value.
 */
function useScrollPass(ref: { current: HTMLElement | null }, on: boolean) {
  useEffect(() => {
    const el = ref.current;
    if (!el || !on || prefersReducedMotion()) return;
    let frame = 0;
    const update = () => {
      frame = 0;
      const r = el.getBoundingClientRect();
      const p = Math.min(1, Math.max(0, (window.innerHeight - r.top) / (r.height + window.innerHeight)));
      el.style.setProperty('--kitfx-p', p.toFixed(4));
    };
    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]);
}

/**
 * A line or tile grid that fades out, with optional cells that light up in
 * the accent color and pulse. Lines run through the middle of the parent, so
 * the grid sits symmetric at every width. Fills its parent, decorative,
 * static with reduced motion.
 */
export function GridFade({ variant = 'lines', cell = 48, fade = 'radial', highlights = 0, pulse = true, seed = 11, scrollReveal = false, className }: GridFadeProps) {
  const ref = useRef<HTMLDivElement>(null);
  useScrollPass(ref, scrollReveal);
  const id = `kitfx-grid-${useId().replace(/[^a-zA-Z0-9_-]/g, '')}`;
  useEffect(() => {
    const el = ref.current;
    if (!el || !pulse || !highlights || typeof IntersectionObserver === 'undefined') return;
    const io = new IntersectionObserver(([e]) => el.style.setProperty('--kitfx-play', e.isIntersecting ? 'running' : 'paused'));
    io.observe(el);
    return () => io.disconnect();
  }, [pulse, highlights]);

  // Cells around the middle, never twice the same
  const rnd = seeded(seed);
  const used = new Set<string>();
  const cells = Array.from({ length: highlights }, () => {
    let i = 0, j = 0;
    for (let n = 0; n < 20; n++) {
      i = Math.floor((rnd() - 0.5) * 16);
      j = Math.floor((rnd() - 0.5) * 9);
      // keep the very middle free for the headline
      if (!used.has(`${i},${j}`) && !(Math.abs(i + 0.5) < 5 && Math.abs(j + 0.5) < 3)) break;
    }
    used.add(`${i},${j}`);
    return { i, j, dur: round(5 + rnd() * 4, 1), delay: round(-rnd() * 9, 1), still: round(0.4 + rnd() * 0.45) };
  });

  const mask = FADE[fade];
  const gap = variant === 'squares' ? Math.max(3, Math.round(cell / 12)) : 0;
  const inset = gap / 2;

  return (
    <div ref={ref} aria-hidden data-clip-ok data-scroll={scrollReveal ? '' : undefined} className={cn('kitfx-grid pointer-events-none absolute inset-0 overflow-hidden', className)} style={mask ? { maskImage: mask, WebkitMaskImage: mask } : undefined}>
      {(highlights > 0 || scrollReveal) && <style dangerouslySetInnerHTML={{ __html: CSS }} />}
      <svg className="absolute inset-0 size-full text-[color-mix(in_oklch,var(--foreground)_9%,transparent)] dark:text-[color-mix(in_oklch,var(--foreground)_10%,transparent)]">
        <defs>
          <pattern id={id} width={cell} height={cell} patternUnits="userSpaceOnUse" x="50%" y="50%">
            {variant === 'squares' ? (
              <rect x={inset} y={inset} width={cell - gap} height={cell - gap} rx={Math.round(cell / 9)} fill="currentColor" fillOpacity={0.45} stroke="currentColor" />
            ) : (
              <path d={`M ${cell} 0.5 H 0.5 V ${cell}`} fill="none" stroke="currentColor" strokeWidth={1} strokeDasharray={variant === 'dashed' ? '3 5' : undefined} />
            )}
          </pattern>
        </defs>
        <rect width="100%" height="100%" fill={`url(#${id})`} />
        {cells.length > 0 && (
          <svg x="50%" y="50%" overflow="visible">
            {cells.map((c, k) => (
              <rect
                key={k}
                x={c.i * cell + (variant === 'squares' ? inset : 1)}
                y={c.j * cell + (variant === 'squares' ? inset : 1)}
                width={variant === 'squares' ? cell - gap : cell - 1}
                height={variant === 'squares' ? cell - gap : cell - 1}
                rx={variant === 'squares' ? Math.round(cell / 9) : 0}
                className="fill-[color-mix(in_oklab,var(--primary)_14%,transparent)] stroke-[color-mix(in_oklab,var(--primary)_40%,transparent)] dark:fill-[color-mix(in_oklab,var(--primary)_22%,transparent)] dark:stroke-[color-mix(in_oklab,var(--primary)_55%,transparent)]"
                style={{ opacity: c.still, animation: pulse ? `kitfx-grid-pulse ${c.dur}s ease-in-out ${c.delay}s infinite` : undefined }}
              />
            ))}
          </svg>
        )}
      </svg>
    </div>
  );
}

Frequent questions

How do I install Grid fade?

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

Which variants does Grid fade have?

3 variants, Lines with pulse, Tiles and Dashed from the top. You pick one with the variant prop, for example variant="squares".

Can an AI assistant build with Grid fade?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Grid fade with aiw_components and install it with aiw_add @ai-website/grid-fade. Name the variant in your prompt, for example variant="lines", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard