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.