Component library
Back to catalog

Twinkling grid

A skewed graph-paper grid with squares lighting up and fading out at random around it, behind a soft radial mask. No JavaScript: an SVG pattern and one CSS keyframe.

backgroundgridpatternsvgcssbackdropno-js

Open preview in a new tab

Use this component

Three ways to get Twinkling grid into one of your own projects. The first two carry the component's files with them, so nothing has to fetch this page.

Start here

Placement prompt

The whole thing: what to do, where it goes, what must not change, and every one of the component's 1 file appended underneath with the path each belongs at. Paste it into Claude Code inside the destination project — it fetches nothing and needs nothing else.

Read it first — the files are added underneath when you copy
Add Twinkling grid from my Astro Component Library to this Astro project.

Library page, for reference only:
https://astro.baysixmedia.com/components/twinkling-grid/

Place it in:
[ENTER PAGE, FILE, OR ROUTE]

Position:
[ENTER WHERE IT SHOULD APPEAR]

Every file you need is appended below, each fenced with the path it belongs at. Write them there, relative to the project root. Do not import anything from the deployed library at runtime, and do not fetch the library page — the files below are the source of truth.

Adapt its demo content, links, colors, typography, and images to this project through documented props where possible. Preserve its responsive behavior, accessibility, interaction logic, reduced-motion behavior, and support for multiple instances. Avoid changing unrelated project code.

Test it at desktop, tablet, and mobile widths. Run the project's available check and build commands. Report the files changed and any differences from the library version. Do not commit or deploy.

Install kit

The 1 file this component needs and nothing else, each fenced with the path it belongs at, followed by what the library's own verification covered and what it did not. No instructions attached — for placing it yourself, or handing it to something that has its own.

Read it first
Install kit: "Twinkling grid"
Library page: https://astro.baysixmedia.com/components/twinkling-grid/
Files: 1 required, 1 supporting

Write each block below to the path named in its header, relative to the project root. A block marked (required) is one the component does not work without; (supporting) is documentation. Nothing here needs downloading except a block that says so in as many words.

===== FILE (required): src/components/TwinklingGrid.astro =====
---
/**
 * Twinkling grid
 *
 * A skewed graph-paper grid with squares lighting up and fading out at random
 * around it, behind a soft radial mask. A background and nothing else: it
 * ships no copy — whatever belongs on top goes in the slot.
 *
 * **No JavaScript at all.** The reference is React plus an animation library
 * and a state update per square per cycle; this is an SVG pattern and one CSS
 * keyframe. Each square carries its own delay and its own three positions as
 * custom properties, so a single shared keyframe animates all of them and the
 * squares still wander rather than blinking in place.
 *
 * Self-contained: no imports, no packages, no global stylesheet, no webfont,
 * no image, no canvas and no script. Every colour and measurement is a
 * `--tgd-*` custom property on the root.
 */

interface Props {
	/**
	 * How the background sits in the page.
	 *
	 * `"section"` is a block in the flow that holds its slot content.
	 * `"layer"` is `position: absolute; inset: 0` — it fills the nearest
	 * positioned ancestor and ignores the slot.
	 * `"page"` is `position: fixed; inset: 0; z-index: -1` — the whole-page
	 * background, and it ignores the slot too.
	 */
	mode?: "section" | "layer" | "page";
	/** Grid cell width, in pixels. The reference calls this `width`. */
	cellWidth?: number;
	/** Grid cell height, in pixels. The reference calls this `height`. */
	cellHeight?: number;
	/** Nudges the whole grid sideways. The reference calls this `x`. */
	offsetX?: number;
	/** Nudges the whole grid up and down. The reference calls this `y`. */
	offsetY?: number;
	/** Dash pattern for the grid lines. `0` is a solid line. */
	dash?: number;
	/** How many squares twinkle. Capped at the number of cells available. */
	squares?: number;
	/** How bright a square gets at the peak of its pulse, 0 to 1. */
	maxOpacity?: number;
	/** How long one square's pulse lasts, in seconds. */
	duration?: number;
	/** How long a square waits between pulses, in seconds. */
	repeatDelay?: number;
	/** Grid lines and squares. Both take this one colour. */
	color?: string;
	/** The page colour behind the grid. */
	background?: string;
	/** How far the grid is skewed, in degrees. The reference uses 12. */
	skew?: number;
	/**
	 * Radius of the radial mask that fades the grid out towards the edges.
	 *
	 * Defaults to `"none"`, so the grid runs the full width. Give it any CSS
	 * length — `"500px"`, `"40rem"`, `"60%"` — to pull it back into a vignette
	 * that sits under content without competing with it.
	 */
	maskRadius?: string;
	/**
	 * How many cells wide and tall the square field is generated over. A
	 * container wider than `columns * cellWidth` will have bare grid past that
	 * point — see the README.
	 */
	columns?: number;
	rows?: number;
	/**
	 * Seed for the placement. The same seed gives the same layout on every
	 * build, which keeps the rendered output diffable.
	 */
	seed?: number;
	/** Height floor in `"section"` mode. */
	minHeight?: string;
	/** Text colour for slot content. `"section"` mode only. */
	ink?: string;
	/** Added to the root element. */
	class?: string;
	/** Root element id. Also seeds the generated keyframe name. */
	id?: string;
}

const {
	mode = "section",
	cellWidth = 40,
	cellHeight = 40,
	offsetX = -1,
	offsetY = -1,
	dash = 0,
	squares = 200,
	maxOpacity = 0.5,
	duration = 2,
	repeatDelay = 1,
	color = "rgb(156 163 175 / 0.3)",
	background = "transparent",
	skew = 12,
	maskRadius = "none",
	columns = 60,
	rows = 40,
	seed = 20260921,
	minHeight = "500px",
	ink = "inherit",
	class: className,
	id,
} = Astro.props;

const layout = mode === "layer" || mode === "page" ? mode : "section";

const num = (value: unknown, fallback: number) =>
	typeof value === "number" && Number.isFinite(value) ? value : fallback;
const clamp = (value: number, min: number, max: number) =>
	Math.min(Math.max(value, min), max);

const cw = clamp(num(cellWidth, 40), 4, 400);
const ch = clamp(num(cellHeight, 40), 4, 400);
const cols = clamp(Math.round(num(columns, 60)), 1, 400);
const rws = clamp(Math.round(num(rows, 40)), 1, 400);
const dur = clamp(num(duration, 2), 0.05, 30);
const gap = clamp(num(repeatDelay, 1), 0, 30);
const peak = clamp(num(maxOpacity, 0.5), 0, 1);
const count = clamp(Math.round(num(squares, 200)), 0, cols * rws);

/*
 * A seeded generator, not Math.random. The placement is baked into the HTML at
 * build time, so an unseeded one would rewrite every square on every build and
 * turn a no-op rebuild into a large diff.
 */
function makeRandom(s: number) {
	let state = (Math.trunc(s) || 1) >>> 0;
	return () => {
		state ^= state << 13;
		state >>>= 0;
		state ^= state >> 17;
		state ^= state << 5;
		state >>>= 0;
		return state / 4294967296;
	};
}
const random = makeRandom(num(seed, 20260921));

const cell = () => [
	Math.floor(random() * cols) * cw,
	Math.floor(random() * rws) * ch,
];

/*
 * Three positions per square, because one CSS animation cannot reposition an
 * element between loops the way the reference's state update does. Each square
 * pulses at position one, slides to position two while it is invisible, pulses
 * again, and so on — so the field wanders instead of the same cells blinking
 * forever.
 */
const items = Array.from({ length: count }, () => {
	const [x, y] = cell();
	const [x2, y2] = cell();
	const [x3, y3] = cell();
	return {
		x,
		y,
		dx2: x2 - x,
		dy2: y2 - y,
		dx3: x3 - x,
		dy3: y3 - y,
		/* Spread the phases across the whole loop so they never pulse in unison. */
		delay: -(random() * (dur + gap) * 3),
	};
});

/*
 * The keyframe percentages depend on `duration` and `repeatDelay`, and CSS
 * cannot take a percentage from a custom property — so one keyframe block is
 * emitted per instance, named for it. Everything else is shared.
 */
const uid = (id ?? `tgd-${Math.random().toString(36).slice(2, 9)}`).replace(
	/[^A-Za-z0-9_-]/g,
	"",
);
const keyframeName = `tgd-twinkle-${uid || "x"}`;

const loop = (dur + gap) * 3;
const pc = (seconds: number) => `${((seconds / loop) * 100).toFixed(3)}%`;
const seg = dur + gap;
const stops = [0, 1, 2].map((i) => ({
	start: pc(i * seg),
	peak: pc(i * seg + dur / 2),
	end: pc(i * seg + dur),
}));

const style = [
	`--tgd-color:${color}`,
	`--tgd-bg:${background}`,
	`--tgd-skew:${num(skew, 12)}deg`,
	`--tgd-mask:${maskRadius}`,
	`--tgd-min-height:${minHeight}`,
	`--tgd-ink:${ink}`,
	`--tgd-peak:${peak}`,
	`--tgd-loop:${loop}s`,
	`--tgd-name:${keyframeName}`,
].join(";");

const patternId = `${uid || "tgd"}-pattern`;
---

<div
	class:list={["twinkling-grid", `tgd-${layout}`, className]}
	id={id}
	style={style}
	data-tgd-root
	data-tgd-mode={layout}
	aria-hidden={layout === "section" ? undefined : "true"}
>
	{/*
	   The percentages below are computed from `duration` and `repeatDelay`, so
	   this block is per-instance. `is:inline` keeps it out of Astro's scoped
	   stylesheet, which is why every selector inside names the instance.
	*/}
	<style is:inline set:html={`
@keyframes ${keyframeName} {
  ${stops[0].start} { opacity: 0; transform: translate(0px, 0px); }
  ${stops[0].peak} { opacity: var(--tgd-peak); }
  ${stops[0].end} { opacity: 0; transform: translate(0px, 0px); }
  ${stops[1].start} { opacity: 0; transform: translate(var(--dx2), var(--dy2)); }
  ${stops[1].peak} { opacity: var(--tgd-peak); }
  ${stops[1].end} { opacity: 0; transform: translate(var(--dx2), var(--dy2)); }
  ${stops[2].start} { opacity: 0; transform: translate(var(--dx3), var(--dy3)); }
  ${stops[2].peak} { opacity: var(--tgd-peak); }
  ${stops[2].end} { opacity: 0; transform: translate(var(--dx3), var(--dy3)); }
  100% { opacity: 0; transform: translate(0px, 0px); }
}
`} />

	<svg class="tgd-svg" aria-hidden="true" focusable="false">
		<defs>
			<pattern
				id={patternId}
				width={cw}
				height={ch}
				patternUnits="userSpaceOnUse"
				x={num(offsetX, -1)}
				y={num(offsetY, -1)}
			>
				<path
					d={`M.5 ${ch}V.5H${cw}`}
					fill="none"
					stroke-dasharray={clamp(num(dash, 0), 0, 200)}
				/>
			</pattern>
		</defs>
		<rect width="100%" height="100%" fill={`url(#${patternId})`} />
		<svg x={num(offsetX, -1)} y={num(offsetY, -1)} class="tgd-squares">
			{
				items.map((s) => (
					<rect
						class="tgd-square"
						width={cw - 1}
						height={ch - 1}
						x={s.x}
						y={s.y}
						style={`--dx2:${s.dx2}px;--dy2:${s.dy2}px;--dx3:${s.dx3}px;--dy3:${s.dy3}px;animation-delay:${s.delay.toFixed(3)}s`}
					/>
				))
			}
		</svg>
	</svg>

	{layout === "section" && (
		<div class="tgd-content">
			<slot />
		</div>
	)}
</div>

<style>
	.twinkling-grid {
		display: block;
		box-sizing: border-box;
		position: relative;
		overflow: hidden;
		background: var(--tgd-bg);
		color: var(--tgd-ink);
	}

	.twinkling-grid *,
	.twinkling-grid *::before,
	.twinkling-grid *::after {
		box-sizing: border-box;
	}

	.tgd-section {
		display: grid;
		place-items: center;
		min-block-size: var(--tgd-min-height);
	}

	.tgd-layer {
		position: absolute;
		inset: 0;
	}

	.tgd-page {
		position: fixed;
		inset: 0;
		z-index: -1;
	}

	/*
	 * Taller than the box and pulled up, because skewing a rectangle leaves
	 * wedges of blank at two corners. At 200% height and -30% top there is
	 * always grid past every edge.
	 */
	.tgd-svg {
		position: absolute;
		inset-inline: 0;
		inset-block: -30%;
		inline-size: 100%;
		block-size: 200%;
		fill: var(--tgd-color);
		stroke: var(--tgd-color);
		transform: skewY(var(--tgd-skew));
		pointer-events: none;
		/* The mask is what turns a full-bleed grid into something that sits
		   under content without competing with it. */
		-webkit-mask-image: radial-gradient(
			var(--tgd-mask) circle at center,
			#fff,
			transparent
		);
		mask-image: radial-gradient(
			var(--tgd-mask) circle at center,
			#fff,
			transparent
		);
	}

	/* `maskRadius: "none"` has to remove the mask rather than pass an invalid
	   length, or the whole grid disappears. */
	.twinkling-grid[style*="--tgd-mask:none"] .tgd-svg {
		-webkit-mask-image: none;
		mask-image: none;
	}

	.tgd-squares {
		overflow: visible;
	}

	.tgd-square {
		fill: currentColor;
		stroke-width: 0;
		opacity: 0;
		animation-name: var(--tgd-name);
		animation-duration: var(--tgd-loop);
		animation-timing-function: ease-in-out;
		animation-iteration-count: infinite;
		animation-fill-mode: both;
	}

	.tgd-content {
		position: relative;
		z-index: 1;
		/* The slot decides its own width and padding. A background that imposed
		   a container on whatever is dropped into it would be a layout
		   component wearing a background's name. */
		inline-size: 100%;
	}

	/* ---- preferences ----------------------------------------------------- */

	@media (prefers-reduced-motion: reduce) {
		/*
		 * The squares stop where they are rather than vanishing. `animation:
		 * none` with the fill mode already applied would leave them at opacity
		 * 0 and the grid would look empty, so they are held at a readable
		 * fraction of the peak instead — the picture with the motion taken out.
		 */
		.tgd-square {
			animation: none;
			opacity: calc(var(--tgd-peak) * 0.45);
			transform: none;
		}
	}

	@media (forced-colors: active) {
		/*
		 * A forced palette has no use for a 30% grey grid, and the mask would
		 * hide whatever it was replaced with. Both come off and the grid is
		 * drawn in CanvasText at the weight the palette chooses.
		 */
		.tgd-svg {
			fill: CanvasText;
			stroke: CanvasText;
			-webkit-mask-image: none;
			mask-image: none;
		}

		.twinkling-grid {
			background: Canvas;
			color: CanvasText;
		}

		/* Twenty per cent of the squares blinking in a forced palette is noise
		   rather than decoration. */
		.tgd-square {
			animation: none;
			opacity: 0;
		}
	}

	@media print {
		.tgd-svg {
			display: none;
		}

		.tgd-page {
			display: none;
		}
	}
</style>

===== END src/components/TwinklingGrid.astro =====

===== FILE (supporting): src/components/TwinklingGrid.md =====
# Twinkling grid

A skewed graph-paper grid with squares lighting up and fading out at random
around it. Full width by default, with an optional radial mask.

**A background and nothing else.** It ships no copy. Whatever belongs on top
goes in the slot.

Catalog page: `/components/twinkling-grid`

## Copying it into another Astro project

Copy **`TwinklingGrid.astro`**. That is the whole component.

| What it needs | Status |
| :--- | :--- |
| Imports from this catalog | none |
| npm packages | **none** |
| Global stylesheet | none — every value is a `--tgd-*` property on the root |
| Webfonts / images / canvas | none |
| **JavaScript** | **none at all** |
| Network requests | none |
| Host requirement | Astro compiles `.astro` files |

**There is no script.** The built page contains **zero `<script>` elements**
from this component — verified against the build output, not just intended.
The reference needs React and an animation library, with a state update per
square per cycle; this is an SVG pattern and one CSS keyframe.

```astro
---
import TwinklingGrid from "../components/TwinklingGrid.astro";
---

<TwinklingGrid>
  <h1>Whatever you want here</h1>
</TwinklingGrid>
```

## The three modes

| `mode` | What it is |
| :--- | :--- |
| `"section"` *(default)* | A block in the flow that **holds** its slot content. |
| `"layer"` | `position: absolute; inset: 0` — fills the nearest positioned ancestor. The slot is ignored. |
| `"page"` | `position: fixed; inset: 0; z-index: -1` — the whole-page background. The slot is ignored. |

In `"layer"` and `"page"` the root is `aria-hidden` — it is decoration with
nothing in it.

## Props

| Prop | Type | Default | Reference calls it | What it does |
| :--- | :--- | :--- | :--- | :--- |
| `mode` | `"section" \| "layer" \| "page"` | `"section"` | — | See above. |
| `cellWidth` | `number` | `40` | `width` | Grid cell width. |
| `cellHeight` | `number` | `40` | `height` | Grid cell height. |
| `offsetX` | `number` | `-1` | `x` | Nudges the grid sideways. |
| `offsetY` | `number` | `-1` | `y` | Nudges the grid vertically. |
| `dash` | `number` | `0` | `strokeDasharray` | Dash pattern. `0` is solid. |
| `squares` | `number` | `200` | `numSquares` | How many twinkle. |
| `maxOpacity` | `number` | `0.5` | `maxOpacity` | Peak brightness of a pulse. |
| `duration` | `number` | `2` | `duration` | One pulse, in seconds. |
| `repeatDelay` | `number` | `1` | `repeatDelay` | Wait between pulses. |
| `color` | `string` | `rgb(156 163 175 / 0.3)` | — | Grid lines **and** squares. |
| `background` | `string` | `"transparent"` | — | Behind the grid. |
| `skew` | `number` | `12` | — | Skew angle in degrees. |
| `maskRadius` | `string` | `"none"` | — | Radial fade. Full width by default. |
| `columns` / `rows` | `number` | `60` / `40` | — | The cell field squares are placed over. |
| `seed` | `number` | `20260921` | — | Placement seed. |
| `minHeight` | `string` | `"500px"` | — | Height floor in `"section"` mode. |
| `ink` | `string` | `"inherit"` | — | Text colour for slot content. |
| `class` / `id` | `string` | — | `className` | Added to the root. |

The reference's `width` and `height` are renamed `cellWidth` and `cellHeight`,
and its `x`/`y` to `offsetX`/`offsetY`. On a component that renders a
full-bleed background, props called `width` and `height` read as the size of
the background rather than the size of a cell, and `width` would collide with
the HTML attribute. Every other prop keeps the reference's name; three
defaults differ — `duration`, `repeatDelay` and `maskRadius` — and are
explained at the end.

## How the twinkle works without JavaScript

The reference gives each square a `motion.rect`, animates its opacity, and on
every animation cycle runs a React state update that moves it to a new random
cell. That needs a framework and a render per square per cycle.

Here, each square is a plain `<rect>` carrying **its own three positions and
its own phase** as custom properties:

```html
<rect class="tgd-square" x="440" y="280"
      style="--dx2:-160px;--dy2:120px;--dx3:80px;--dy3:-240px;animation-delay:-3.71s" />
```

One shared `@keyframes` reads those properties, so all 200 squares animate from
a single rule. Each loop pulses at position one, **slides to position two while
its opacity is zero**, pulses again, slides to position three, pulses, and
returns. The movement is invisible because it only ever happens between pulses,
so the field wanders exactly as the reference's does.

Measured on the rendered component: one square produced **13 distinct
transforms over four seconds**, so it genuinely moves rather than blinking in
one cell.

### Why there is a `<style>` block per instance

The keyframe percentages are computed from `duration` and `repeatDelay`, and
CSS cannot take a percentage from a custom property. So one small `@keyframes`
block is emitted per instance, named after its id. Everything else — the
selectors, the property names — is shared and scoped normally.

Two instances on one page get different keyframe names, different pattern ids
and different everything else; there is nothing global to collide.

### Placement is seeded

Positions are generated at build time from a seeded PRNG, not `Math.random()`.
An unseeded one would rewrite all 200 squares on every build and turn a no-op
rebuild into a large diff. Verified: the square geometry hashes identically
across two consecutive builds.

Change `seed` for a different arrangement.

## The one real limitation

The reference measures its own box in JavaScript and redistributes the squares
whenever it resizes. With no script, this cannot.

Instead, squares are placed on a fixed field of `columns` × `rows` cells —
2400 × 1600px by default. **A container wider than `columns * cellWidth` will
show bare grid past that point.** In practice you will not hit it: at the
default 60 columns the field is 2400px wide.

Measured square counts inside the viewport: 172 at 1920px, 109 at 1280px, 28
at 390px, 25 at 320px. Narrow containers show fewer squares because fewer cells
are on screen — the density per visible area stays about the same.

## Tuning it

- **`maskRadius`** — the single biggest lever, and the default is `"none"`,
  so the grid runs edge to edge. Give it a length for a vignette that sits
  under content without competing with it: `"500px"` is the reference's, and
  `"40rem"` or `"60%"` work too.
- **`squares`** — 200 over a 2400×1600 field is sparse. 600 is busy.
- **`color`** — takes the grid lines *and* the squares. Put the alpha in the
  colour, as the default does.
- **`skew`** — `0` gives a flat grid. Negative leans the other way.

## Accessibility

- The whole SVG is `aria-hidden` and `pointer-events: none`. In `"layer"` and
  `"page"` the root is too.
- The component contributes **nothing focusable**. Anything focusable on a page
  using it belongs to the slot.
- `prefers-reduced-motion: reduce` **stops the squares where they are** and
  holds them at 45% of `maxOpacity`. It deliberately does not just switch the
  animation off: the fill mode would leave every square at opacity 0 and the
  grid would look empty. Measured: 200 squares lit and stable, grid intact.
- `forced-colors: active` redraws the grid in `CanvasText`, removes the mask —
  which would otherwise hide whatever the palette substituted — and **turns the
  squares off entirely**. Two hundred rectangles blinking in a forced palette
  is noise, not decoration.
- `@media print` drops the grid, and drops `"page"` mode so a fixed background
  does not print on every sheet.

## Relationship to the reference

Rebuilt from a published React component. None of it is reproduced and neither
React nor an animation library is installed.

Matched from the rendered original: the 40×40 pattern anchored at `(-1, -1)`,
the cell path `M.5 40V.5H40`, `skewY(12deg)`, the SVG at 200% height inset
−30% vertically, its 500px radial mask, 39×39 squares on cell multiples, and
`gray-400` at 30% for both fill and stroke. The prop names are carried across
except for the four renames noted above.

Three defaults differ from the reference, changed on request after the first
build: `duration` 1 → **2** and `repeatDelay` 0.5 → **1**, which halves the
pace (a 4.5s loop becomes 9s), and `maskRadius` `"500px"` → **`"none"`**, so
the grid runs the full width instead of fading to a centred vignette. Pass the
reference's values to get the reference's behaviour back.

Two differences beyond the renames:

1. **No JavaScript**, where the reference needs React and framer-motion.
2. **Squares are placed on a fixed cell field** rather than measured and
   redistributed on resize — the trade for the first point, and the limitation
   is documented above rather than left to be discovered.

===== END src/components/TwinklingGrid.md =====

--- VERIFIED IN THE COMPONENT LIBRARY ---
Production-ready in the library.
Verified 2026-09-21 at 1920, 1440, 1280, 1024, 834, 768, 600, 430, 390, 360, 320px.
Passed: full-page context, keyboard, no javascript, reduced motion, two instances, no overflow, declarations corroborated, lifecycle.
Library source fingerprint: sha256:38959d7df9a2c630594d571251fb0b7b471c6429abd226d9b09de17af8a518ab — MATCHES the files below.

--- NOT PROVEN ---
- ClientRouter: not applicable — this component ships no script.

--- WHAT THIS DOES NOT CERTIFY ---
Verification was performed on the unmodified component, in an isolated fixture, in the
library. It says nothing about this project's CSS, routes, layout or data, and nothing
about the component once you adapt it. A verified component is a good starting point,
not a guarantee about the installation you are about to build.

--- TEST IN THIS PROJECT AFTER INSTALLING OR CUSTOMISING ---
- Horizontal overflow at 1440, 1280, 1024, 834, 390 and 320px.
- Keyboard operation end to end, including visible focus.
- The no-JavaScript render.
- prefers-reduced-motion: reduce.
- Two instances on one page, if this project renders more than one.
- If this project uses <ClientRouter />: teardown and reinitialisation across a real navigation.

Component reference

A fragment for a request you write yourself, e.g. “I want to add [paste] to Section 3 of my homepage.” It names the component, links its page, and fixes what must not change — the design build and its effects — while leaving branding and content open. It does not carry the code.

Read it first
“Twinkling grid” from my Astro Component Library:
https://astro.baysixmedia.com/components/twinkling-grid/

Use the existing library component as the implementation source. Preserve its design structure, layout, proportions, spacing system, responsive behavior, visual effects, animations, transitions, hover states, interactions, and accessibility.

The copy, colors, typography, images, and other content may be adapted to match the destination website. Do not redesign, simplify, or reinterpret the component’s underlying design build or effects.

Component readiness

Judged on recorded evidence, not on how it looks in the frame above. A description of a component is not a result.

Interactivity
Static
Profile
core only
Installation files
1 required + 1 supporting
Verified
2026-09-21
Client navigation
Not applicable — ships no script
Source fingerprint
Matches the verified source
Production ready
Yes

Production-ready component

Every required check passed against the code below, and the recorded fingerprint still matches it.

Verified 2026-09-21 at 1920, 1440, 1280, 1024, 834, 768, 600, 430, 390, 360, 320px

  • Full-page contextpass
  • Keyboard — declared to contain nothing focusablenot-applicable
  • No JavaScriptpass
  • Reduced motionpass
  • Two instancespass
  • No overflowpass
  • Declarations corroboratedpass
  • Lifecycle — declared to ship no scriptnot-applicable
19 verification notes
  • Rebuilt from a published React component. None of it is reproduced and neither React nor its animation library is installed. This library is Astro, so the shadcn structure and components/ui folder the prompt described do not apply to it.
  • No JavaScript at all, which is the headline difference. The reference gives every square a motion element and runs a state update per square per cycle to move it; this is an SVG pattern and one CSS keyframe. Verified against the build output rather than asserted: the preview page and the two-instance fixture both contain zero script elements.
  • Matched from the rendered original: the 40x40 pattern anchored at -1,-1, the cell path M.5 40V.5H40, skewY(12deg) - read as matrix 0.212557, which is tan 12 - the SVG at 200% height inset -30% vertically, its 500px radial mask, 39x39 squares on cell multiples, and gray-400 at 30% for both fill and stroke.
  • The squares wander rather than blinking in place, and without a script. Each rect carries its own three positions and its own phase as custom properties; one shared keyframe reads them, pulses at position one, slides to position two while opacity is zero, pulses again, and so on. The movement is invisible because it only happens between pulses. Measured on the rendered component: one square produced 13 distinct transform values over four seconds.
  • One style block is emitted per instance because the keyframe percentages are computed from duration and repeatDelay, and CSS cannot take a percentage from a custom property. Everything else is shared and scoped normally. Verified on the fixture: two instances get different keyframe names, different pattern ids, and each root points at its own.
  • Placement is seeded rather than random. An unseeded generator would rewrite all 200 squares on every build and turn a no-op rebuild into a large diff. Verified: the square geometry hashes identically across two consecutive builds.
  • Four props are renamed from the reference and the rest keep its names and defaults. width and height become cellWidth and cellHeight, and x and y become offsetX and offsetY, because on a component that renders a full-bleed background a prop called width reads as the size of the background rather than of a cell, and width would collide with the HTML attribute. The mapping is in the README table.
  • One real limitation, stated rather than hidden. The reference measures its own box in JavaScript and redistributes the squares on resize; with no script this cannot, so squares are placed on a fixed field of columns by rows cells, 2400 by 1600px by default. A container wider than that shows bare grid past it. Measured square counts inside the viewport: 172 at 1920, 109 at 1280, 28 at 390 and 25 at 320, so the density per visible area holds up across the range.
  • Reduced motion stops the squares where they are and holds them at 45% of maxOpacity. It deliberately does not simply switch the animation off: the fill mode would leave every square at opacity 0 and the grid would look empty. Measured under emulation - animation-name none, opacity and transform both stable across a second, 200 squares lit, grid intact.
  • Forced colours redraws the grid in CanvasText, removes the mask - which would otherwise hide whatever the palette substituted - and turns the squares off entirely, because two hundred rectangles blinking in a forced palette is noise rather than decoration. Measured under emulation: fill and stroke CanvasText, mask none, square opacity 0, grid still drawn.
  • Print drops the grid, and drops page mode so a fixed background does not print on every sheet.
  • Verified at eleven widths from 1920 to 320: no page overflow at any of them, with the root clipping the skewed overhang that the 200%-height SVG deliberately produces.
  • Two instances on one page stay independent, and there is nothing global for them to collide over. The fixture's second instance declares a coarser 64px grid, 60 squares, a different seed, a blue at 35%, a 2s pulse, a negative skew and a 420px mask where the first has none; both render with their own pattern id correctly referenced, their own keyframe name, their own loop length - 9s against 6.75s - and no duplicate ids anywhere on the page.
  • clientNavigation is not-applicable rather than untested: there is no script, so there is nothing a client-side router could strand, re-initialise or leak. lifecycle is not-applicable for the same reason.
  • keyboard is not-applicable and the declaration licenses it: the component contributes nothing focusable in any mode. Measured on the fixture: zero focusable controls.
  • The squares are decorative and the grid is faint by design - gray-400 at 30%, with the squares peaking at 0.5 of that. Nothing here is load-bearing for contrast, but a caller who raises maxOpacity or the colour's alpha under body text should check it themselves.
  • Declarations corroborated against the rendered fixture: zero focusable controls against hasFocusableContent false, a running CSS animation against hasMotion, the id prop reaching both roots and driving both the pattern id and the keyframe name against acceptsIdProp, and zero scripts against interactivity static.
  • No console errors or warnings, and no external requests of any kind.
  • Chromium only. No WebKit or Gecko build was available in this session and no claim is made about either. The features that gap matters most for here are mask-image on an SVG element and var() resolved inside a keyframe, both of which are long settled but were not observed elsewhere.

Open the verification fixture

src/components/library/TwinklingGrid.astro

one fileNo imports, no companion files, no packages. Copy TwinklingGrid.astro into any Astro project's components folder and render it.

---
/**
 * Twinkling grid
 *
 * A skewed graph-paper grid with squares lighting up and fading out at random
 * around it, behind a soft radial mask. A background and nothing else: it
 * ships no copy — whatever belongs on top goes in the slot.
 *
 * **No JavaScript at all.** The reference is React plus an animation library
 * and a state update per square per cycle; this is an SVG pattern and one CSS
 * keyframe. Each square carries its own delay and its own three positions as
 * custom properties, so a single shared keyframe animates all of them and the
 * squares still wander rather than blinking in place.
 *
 * Self-contained: no imports, no packages, no global stylesheet, no webfont,
 * no image, no canvas and no script. Every colour and measurement is a
 * `--tgd-*` custom property on the root.
 */

interface Props {
	/**
	 * How the background sits in the page.
	 *
	 * `"section"` is a block in the flow that holds its slot content.
	 * `"layer"` is `position: absolute; inset: 0` — it fills the nearest
	 * positioned ancestor and ignores the slot.
	 * `"page"` is `position: fixed; inset: 0; z-index: -1` — the whole-page
	 * background, and it ignores the slot too.
	 */
	mode?: "section" | "layer" | "page";
	/** Grid cell width, in pixels. The reference calls this `width`. */
	cellWidth?: number;
	/** Grid cell height, in pixels. The reference calls this `height`. */
	cellHeight?: number;
	/** Nudges the whole grid sideways. The reference calls this `x`. */
	offsetX?: number;
	/** Nudges the whole grid up and down. The reference calls this `y`. */
	offsetY?: number;
	/** Dash pattern for the grid lines. `0` is a solid line. */
	dash?: number;
	/** How many squares twinkle. Capped at the number of cells available. */
	squares?: number;
	/** How bright a square gets at the peak of its pulse, 0 to 1. */
	maxOpacity?: number;
	/** How long one square's pulse lasts, in seconds. */
	duration?: number;
	/** How long a square waits between pulses, in seconds. */
	repeatDelay?: number;
	/** Grid lines and squares. Both take this one colour. */
	color?: string;
	/** The page colour behind the grid. */
	background?: string;
	/** How far the grid is skewed, in degrees. The reference uses 12. */
	skew?: number;
	/**
	 * Radius of the radial mask that fades the grid out towards the edges.
	 *
	 * Defaults to `"none"`, so the grid runs the full width. Give it any CSS
	 * length — `"500px"`, `"40rem"`, `"60%"` — to pull it back into a vignette
	 * that sits under content without competing with it.
	 */
	maskRadius?: string;
	/**
	 * How many cells wide and tall the square field is generated over. A
	 * container wider than `columns * cellWidth` will have bare grid past that
	 * point — see the README.
	 */
	columns?: number;
	rows?: number;
	/**
	 * Seed for the placement. The same seed gives the same layout on every
	 * build, which keeps the rendered output diffable.
	 */
	seed?: number;
	/** Height floor in `"section"` mode. */
	minHeight?: string;
	/** Text colour for slot content. `"section"` mode only. */
	ink?: string;
	/** Added to the root element. */
	class?: string;
	/** Root element id. Also seeds the generated keyframe name. */
	id?: string;
}

const {
	mode = "section",
	cellWidth = 40,
	cellHeight = 40,
	offsetX = -1,
	offsetY = -1,
	dash = 0,
	squares = 200,
	maxOpacity = 0.5,
	duration = 2,
	repeatDelay = 1,
	color = "rgb(156 163 175 / 0.3)",
	background = "transparent",
	skew = 12,
	maskRadius = "none",
	columns = 60,
	rows = 40,
	seed = 20260921,
	minHeight = "500px",
	ink = "inherit",
	class: className,
	id,
} = Astro.props;

const layout = mode === "layer" || mode === "page" ? mode : "section";

const num = (value: unknown, fallback: number) =>
	typeof value === "number" && Number.isFinite(value) ? value : fallback;
const clamp = (value: number, min: number, max: number) =>
	Math.min(Math.max(value, min), max);

const cw = clamp(num(cellWidth, 40), 4, 400);
const ch = clamp(num(cellHeight, 40), 4, 400);
const cols = clamp(Math.round(num(columns, 60)), 1, 400);
const rws = clamp(Math.round(num(rows, 40)), 1, 400);
const dur = clamp(num(duration, 2), 0.05, 30);
const gap = clamp(num(repeatDelay, 1), 0, 30);
const peak = clamp(num(maxOpacity, 0.5), 0, 1);
const count = clamp(Math.round(num(squares, 200)), 0, cols * rws);

/*
 * A seeded generator, not Math.random. The placement is baked into the HTML at
 * build time, so an unseeded one would rewrite every square on every build and
 * turn a no-op rebuild into a large diff.
 */
function makeRandom(s: number) {
	let state = (Math.trunc(s) || 1) >>> 0;
	return () => {
		state ^= state << 13;
		state >>>= 0;
		state ^= state >> 17;
		state ^= state << 5;
		state >>>= 0;
		return state / 4294967296;
	};
}
const random = makeRandom(num(seed, 20260921));

const cell = () => [
	Math.floor(random() * cols) * cw,
	Math.floor(random() * rws) * ch,
];

/*
 * Three positions per square, because one CSS animation cannot reposition an
 * element between loops the way the reference's state update does. Each square
 * pulses at position one, slides to position two while it is invisible, pulses
 * again, and so on — so the field wanders instead of the same cells blinking
 * forever.
 */
const items = Array.from({ length: count }, () => {
	const [x, y] = cell();
	const [x2, y2] = cell();
	const [x3, y3] = cell();
	return {
		x,
		y,
		dx2: x2 - x,
		dy2: y2 - y,
		dx3: x3 - x,
		dy3: y3 - y,
		/* Spread the phases across the whole loop so they never pulse in unison. */
		delay: -(random() * (dur + gap) * 3),
	};
});

/*
 * The keyframe percentages depend on `duration` and `repeatDelay`, and CSS
 * cannot take a percentage from a custom property — so one keyframe block is
 * emitted per instance, named for it. Everything else is shared.
 */
const uid = (id ?? `tgd-${Math.random().toString(36).slice(2, 9)}`).replace(
	/[^A-Za-z0-9_-]/g,
	"",
);
const keyframeName = `tgd-twinkle-${uid || "x"}`;

const loop = (dur + gap) * 3;
const pc = (seconds: number) => `${((seconds / loop) * 100).toFixed(3)}%`;
const seg = dur + gap;
const stops = [0, 1, 2].map((i) => ({
	start: pc(i * seg),
	peak: pc(i * seg + dur / 2),
	end: pc(i * seg + dur),
}));

const style = [
	`--tgd-color:${color}`,
	`--tgd-bg:${background}`,
	`--tgd-skew:${num(skew, 12)}deg`,
	`--tgd-mask:${maskRadius}`,
	`--tgd-min-height:${minHeight}`,
	`--tgd-ink:${ink}`,
	`--tgd-peak:${peak}`,
	`--tgd-loop:${loop}s`,
	`--tgd-name:${keyframeName}`,
].join(";");

const patternId = `${uid || "tgd"}-pattern`;
---

<div
	class:list={["twinkling-grid", `tgd-${layout}`, className]}
	id={id}
	style={style}
	data-tgd-root
	data-tgd-mode={layout}
	aria-hidden={layout === "section" ? undefined : "true"}
>
	{/*
	   The percentages below are computed from `duration` and `repeatDelay`, so
	   this block is per-instance. `is:inline` keeps it out of Astro's scoped
	   stylesheet, which is why every selector inside names the instance.
	*/}
	<style is:inline set:html={`
@keyframes ${keyframeName} {
  ${stops[0].start} { opacity: 0; transform: translate(0px, 0px); }
  ${stops[0].peak} { opacity: var(--tgd-peak); }
  ${stops[0].end} { opacity: 0; transform: translate(0px, 0px); }
  ${stops[1].start} { opacity: 0; transform: translate(var(--dx2), var(--dy2)); }
  ${stops[1].peak} { opacity: var(--tgd-peak); }
  ${stops[1].end} { opacity: 0; transform: translate(var(--dx2), var(--dy2)); }
  ${stops[2].start} { opacity: 0; transform: translate(var(--dx3), var(--dy3)); }
  ${stops[2].peak} { opacity: var(--tgd-peak); }
  ${stops[2].end} { opacity: 0; transform: translate(var(--dx3), var(--dy3)); }
  100% { opacity: 0; transform: translate(0px, 0px); }
}
`} />

	<svg class="tgd-svg" aria-hidden="true" focusable="false">
		<defs>
			<pattern
				id={patternId}
				width={cw}
				height={ch}
				patternUnits="userSpaceOnUse"
				x={num(offsetX, -1)}
				y={num(offsetY, -1)}
			>
				<path
					d={`M.5 ${ch}V.5H${cw}`}
					fill="none"
					stroke-dasharray={clamp(num(dash, 0), 0, 200)}
				/>
			</pattern>
		</defs>
		<rect width="100%" height="100%" fill={`url(#${patternId})`} />
		<svg x={num(offsetX, -1)} y={num(offsetY, -1)} class="tgd-squares">
			{
				items.map((s) => (
					<rect
						class="tgd-square"
						width={cw - 1}
						height={ch - 1}
						x={s.x}
						y={s.y}
						style={`--dx2:${s.dx2}px;--dy2:${s.dy2}px;--dx3:${s.dx3}px;--dy3:${s.dy3}px;animation-delay:${s.delay.toFixed(3)}s`}
					/>
				))
			}
		</svg>
	</svg>

	{layout === "section" && (
		<div class="tgd-content">
			<slot />
		</div>
	)}
</div>

<style>
	.twinkling-grid {
		display: block;
		box-sizing: border-box;
		position: relative;
		overflow: hidden;
		background: var(--tgd-bg);
		color: var(--tgd-ink);
	}

	.twinkling-grid *,
	.twinkling-grid *::before,
	.twinkling-grid *::after {
		box-sizing: border-box;
	}

	.tgd-section {
		display: grid;
		place-items: center;
		min-block-size: var(--tgd-min-height);
	}

	.tgd-layer {
		position: absolute;
		inset: 0;
	}

	.tgd-page {
		position: fixed;
		inset: 0;
		z-index: -1;
	}

	/*
	 * Taller than the box and pulled up, because skewing a rectangle leaves
	 * wedges of blank at two corners. At 200% height and -30% top there is
	 * always grid past every edge.
	 */
	.tgd-svg {
		position: absolute;
		inset-inline: 0;
		inset-block: -30%;
		inline-size: 100%;
		block-size: 200%;
		fill: var(--tgd-color);
		stroke: var(--tgd-color);
		transform: skewY(var(--tgd-skew));
		pointer-events: none;
		/* The mask is what turns a full-bleed grid into something that sits
		   under content without competing with it. */
		-webkit-mask-image: radial-gradient(
			var(--tgd-mask) circle at center,
			#fff,
			transparent
		);
		mask-image: radial-gradient(
			var(--tgd-mask) circle at center,
			#fff,
			transparent
		);
	}

	/* `maskRadius: "none"` has to remove the mask rather than pass an invalid
	   length, or the whole grid disappears. */
	.twinkling-grid[style*="--tgd-mask:none"] .tgd-svg {
		-webkit-mask-image: none;
		mask-image: none;
	}

	.tgd-squares {
		overflow: visible;
	}

	.tgd-square {
		fill: currentColor;
		stroke-width: 0;
		opacity: 0;
		animation-name: var(--tgd-name);
		animation-duration: var(--tgd-loop);
		animation-timing-function: ease-in-out;
		animation-iteration-count: infinite;
		animation-fill-mode: both;
	}

	.tgd-content {
		position: relative;
		z-index: 1;
		/* The slot decides its own width and padding. A background that imposed
		   a container on whatever is dropped into it would be a layout
		   component wearing a background's name. */
		inline-size: 100%;
	}

	/* ---- preferences ----------------------------------------------------- */

	@media (prefers-reduced-motion: reduce) {
		/*
		 * The squares stop where they are rather than vanishing. `animation:
		 * none` with the fill mode already applied would leave them at opacity
		 * 0 and the grid would look empty, so they are held at a readable
		 * fraction of the peak instead — the picture with the motion taken out.
		 */
		.tgd-square {
			animation: none;
			opacity: calc(var(--tgd-peak) * 0.45);
			transform: none;
		}
	}

	@media (forced-colors: active) {
		/*
		 * A forced palette has no use for a 30% grey grid, and the mask would
		 * hide whatever it was replaced with. Both come off and the grid is
		 * drawn in CanvasText at the weight the palette chooses.
		 */
		.tgd-svg {
			fill: CanvasText;
			stroke: CanvasText;
			-webkit-mask-image: none;
			mask-image: none;
		}

		.twinkling-grid {
			background: Canvas;
			color: CanvasText;
		}

		/* Twenty per cent of the squares blinking in a forced palette is noise
		   rather than decoration. */
		.tgd-square {
			animation: none;
			opacity: 0;
		}
	}

	@media print {
		.tgd-svg {
			display: none;
		}

		.tgd-page {
			display: none;
		}
	}
</style>