Grid LinesA dot grid connected by thin lines.
'use client'
import { useLayoutEffect, useEffect, useRef, useState } from 'react'
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect
// ─── Config ───────────────────────────────────────────────────────────────────
const SPACING = 20 // px between dot/node centres
const RADIUS_FRAC = 0.30 // hover influence radius — fraction of max(cw, ch)
const LENS_FRAC = 0.06 // lens push strength — fraction of R
const BASE_A = 0.13 // resting dot opacity
const PEAK_A = 0.95 // fully-lit opacity
const LINE_A_DARK = 0.07 // resting line opacity (dark theme)
const LINE_A_LIGHT = 0.12 // resting line opacity (light theme)
const MOUSE_LERP = 0.14 // smoothed mouse movement
// ─── Types ────────────────────────────────────────────────────────────────────
// b = brightness (0..1, smoothed)
// l = lens influence (0..1, smoothed) — bell curve over distance from cursor
// px/py = current displaced position (recomputed each frame)
type Dot = { x: number; y: number; b: number; l: number; px: number; py: number }
type Segment = { a: Dot; b: Dot }
// ─── Component ────────────────────────────────────────────────────────────────
export default function GridLines() {
const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const mouseRef = useRef<{ x: number; y: number } | null>(null)
const isDarkRef = useRef(typeof window !== 'undefined' ? document.documentElement.classList.contains('dark') : false)
const [isDark, setIsDark] = useState(() => typeof window !== 'undefined' ? document.documentElement.classList.contains('dark') : false)
// ── Theme detection ──────────────────────────────────────────────────────────
useIsomorphicLayoutEffect(() => {
const el = containerRef.current
if (!el) return
const check = () => {
const card = el.closest('[data-card-theme]')
const dark = card
? card.classList.contains('dark')
: document.documentElement.classList.contains('dark')
setIsDark(dark)
isDarkRef.current = dark
}
check()
const observer = new MutationObserver(check)
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
const cardWrapper = el.closest('[data-card-theme]')
if (cardWrapper) observer.observe(cardWrapper, { attributes: true, attributeFilter: ['class'] })
return () => observer.disconnect()
}, [])
// ── Canvas render loop ───────────────────────────────────────────────────────
useEffect(() => {
const canvas: HTMLCanvasElement = canvasRef.current!
const ctx = canvas.getContext('2d')!
let dots: Dot[] = []
let hSegs: Segment[] = []
let vSegs: Segment[] = []
let animId = 0
let alive = true
let cw = 0, ch = 0
let smoothMx = -99999
let smoothMy = -99999
function build() {
const dpr = window.devicePixelRatio || 1
const rect = canvas.getBoundingClientRect()
cw = rect.width
ch = rect.height
if (!cw || !ch) return
canvas.width = Math.round(cw * dpr)
canvas.height = Math.round(ch * dpr)
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
const cols = Math.floor(cw / SPACING) + 2
const rows = Math.floor(ch / SPACING) + 2
const ox = (cw % SPACING) / 2
const oy = (ch % SPACING) / 2
// Map existing dots by position to persist state during resize
const prev = new Map<string, Dot>()
for (const d of dots) {
prev.set(`${d.x.toFixed(0)},${d.y.toFixed(0)}`, d)
}
// Build dot grid as 2D array for easy neighbour lookup
const grid: Dot[][] = []
dots = []
for (let r = 0; r < rows; r++) {
grid[r] = []
for (let c = 0; c < cols; c++) {
const x = ox + c * SPACING
const y = oy + r * SPACING
const key = `${x.toFixed(0)},${y.toFixed(0)}`
const d: Dot = prev.get(key) ?? { x, y, b: 0, l: 0, px: x, py: y }
dots.push(d)
grid[r][c] = d
}
}
// Build segments — horizontal and vertical only
hSegs = []
vSegs = []
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (c + 1 < cols) hSegs.push({ a: grid[r][c], b: grid[r][c + 1] })
if (r + 1 < rows) vSegs.push({ a: grid[r][c], b: grid[r + 1][c] })
}
}
}
function frame() {
if (!alive) return
ctx.clearRect(0, 0, cw, ch)
const raw = mouseRef.current
if (raw) {
if (smoothMx === -99999) { smoothMx = raw.x; smoothMy = raw.y }
smoothMx += (raw.x - smoothMx) * MOUSE_LERP
smoothMy += (raw.y - smoothMy) * MOUSE_LERP
} else {
smoothMx = -99999
smoothMy = -99999
}
const mx = smoothMx
const my = smoothMy
const R = RADIUS_FRAC * Math.max(cw, ch)
const r2 = R * R
const lensPush = LENS_FRAC * R
const dotRGB = isDarkRef.current ? '255,255,255' : '28,25,22'
const baseA = isDarkRef.current ? BASE_A : 0.22
const lineRestA = isDarkRef.current ? LINE_A_DARK : LINE_A_LIGHT
// ── 1. Per-dot update: brightness, lens influence, displaced position ──
// Brightness uses a Gaussian halo (soft blend into the background).
// Lens uses a sin(πt) bell curve so dots at mid-distance get the
// strongest outward push, dots at the cursor and at the edge of R
// stay put — the grid bulges around the cursor like a lens.
for (const d of dots) {
const dx = d.x - mx
const dy = d.y - my
const dist2 = dx * dx + dy * dy
const dist = Math.sqrt(dist2)
// Brightness — Gaussian
const tgtB = dist2 < r2 ? Math.exp(-dist2 / (r2 * 0.45)) : 0
d.b += (tgtB > d.b ? 0.16 : 0.07) * (tgtB - d.b)
if (d.b < 0.004) d.b = 0
// Lens influence — bell curve, peaks at mid-distance
const tgtL = dist < R ? Math.sin(Math.PI * (dist / R)) : 0
d.l += (tgtL > d.l ? 0.18 : 0.08) * (tgtL - d.l)
if (d.l < 0.004) d.l = 0
// Displaced position — push outward along the cursor→dot ray
if (dist > 0.5 && d.l > 0.004) {
const push = lensPush * d.l
const ux = dx / dist
const uy = dy / dist
d.px = d.x + ux * push
d.py = d.y + uy * push
} else {
d.px = d.x
d.py = d.y
}
}
// ── 2. Draw lines through displaced dot positions ──────────────────────
// Because both endpoints move, lines bend as they cross the lens area,
// making the grid visibly warp.
const allSegs = [...hSegs, ...vSegs]
for (const seg of allSegs) {
const segB = (seg.a.b + seg.b.b) / 2
const lineA = lineRestA + (PEAK_A - lineRestA) * segB
ctx.strokeStyle = `rgba(${dotRGB},${lineA.toFixed(3)})`
ctx.lineWidth = 0.5 + segB * 0.6
ctx.beginPath()
ctx.moveTo(seg.a.px, seg.a.py)
ctx.lineTo(seg.b.px, seg.b.py)
ctx.stroke()
}
// ── 3. Draw dots on top, at displaced positions ────────────────────────
for (const d of dots) {
const alpha = baseA + (PEAK_A - baseA) * d.b
const sz = 1 + d.b * 2.2
ctx.fillStyle = `rgba(${dotRGB},${alpha.toFixed(2)})`
ctx.fillRect(d.px - sz / 2, d.py - sz / 2, sz, sz)
}
animId = requestAnimationFrame(frame)
}
build()
frame()
const ro = new ResizeObserver(build)
ro.observe(canvas.parentElement!)
return () => {
alive = false
cancelAnimationFrame(animId)
ro.disconnect()
}
}, [])
function updateMouse(clientX: number, clientY: number) {
const rect = canvasRef.current?.getBoundingClientRect()
if (!rect) return
mouseRef.current = { x: clientX - rect.left, y: clientY - rect.top }
}
const bg = isDark ? '#110F0C' : '#F5F1EA'
const labelColor = isDark ? 'rgba(255,255,255,0.45)' : 'rgba(28,25,22,0.45)'
const hintColor = isDark ? 'rgba(255,255,255,0.18)' : 'rgba(28,25,22,0.22)'
return (
<div
ref={containerRef}
className="relative min-h-screen w-full overflow-hidden"
style={{ background: bg }}
onMouseMove={(e) => updateMouse(e.clientX, e.clientY)}
onMouseLeave={() => { mouseRef.current = null }}
onTouchMove={(e) => { const t = e.touches[0]; if (t) updateMouse(t.clientX, t.clientY) }}
onTouchEnd={() => { mouseRef.current = null }}
>
<canvas
ref={canvasRef}
className="absolute inset-0"
style={{ width: '100%', height: '100%' }}
/>
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center gap-2">
<span style={{ color: labelColor, fontSize: 22, fontWeight: 700, letterSpacing: '-0.02em' }}>
Grid Lines
</span>
<span style={{ color: hintColor, fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.12em' }}>
hover to illuminate
</span>
</div>
</div>
)
}
Add to your project
One command adds this component to your project.
Run the following command. New project? Run npx shadcn@latest init first to set up Tailwind and path aliases.
npx shadcn@latest add @aicanvas/grid-linesFor dark mode, add the dark class to your <html> element:
<html class="dark">Install with AI Canvas MCP
With AI Canvas MCP, your AI knows every component we ship. Ask for “a navigation component from AI Canvas” inside Claude Code, Codex, or Cursor and it can suggest you a few options, then install the one you like. Less typing, lower token cost, modern way to build.
Get MCPAbout Grid Lines
Grid Lines connects a dot grid with thin segments and waits for the cursor to enter. When it does, a radial pulse fires from your pointer and ripples outward, lighting both dots and connecting strokes as it spreads. The whole composition is drawn into a single 2D canvas with Tailwind providing layout for the container around it. It works well as a hero backdrop where a single interaction signals "this site is alive", or as a section break on a long marketing page.


