Cardwall

Perspective billboard wall with drifting cards.

Live demo

01

Default Β· 5 rows

8 tiles per row, 220 px tile width β€” the headline configuration.

CIPHER
CANVAS
CRAFT
SPARK
WARP
SIGNAL
ECHO
STATUS
PROOF
SIGNAL
ECHO
PRELUDE
NORTH
CANVAS
INDIGO
STORY
DRIFT
ECHO
PRELUDE
NORTH
OFFLINE
INDIGO
CRAFT
STILL
SPARK
WARP
SIGNAL
OFFLINE
STATUS
CRAFT
STILL
PROOF
STATUS
CIPHER
CANVAS
INDIGO
STORY
CIPHER
CANVAS
CRAFT

Sparse Β· 3 rows

Quieter β€” suits hero sections that want the wall as a backdrop.

CIPHER
CANVAS
CRAFT
SPARK
WARP
SIGNAL
PROOF
SIGNAL
ECHO
PRELUDE
NORTH
CANVAS
DRIFT
ECHO
PRELUDE
NORTH
OFFLINE
INDIGO

Dense Β· 7 rows

Cinematic city of tiles β€” slightly wider cells keep dense labels readable.

CIPHER
CANVAS
CRAFT
SPARK
WARP
SIGNAL
ECHO
STATUS
STORY
CIPHER
PROOF
SIGNAL
ECHO
PRELUDE
NORTH
CANVAS
INDIGO
STORY
CIPHER
CANVAS
DRIFT
ECHO
PRELUDE
NORTH
OFFLINE
INDIGO
CRAFT
STILL
WARP
SIGNAL
SPARK
WARP
SIGNAL
OFFLINE
STATUS
CRAFT
STILL
PROOF
DRIFT
ECHO
STATUS
CIPHER
CANVAS
INDIGO
STORY
CIPHER
CANVAS
CRAFT
SPARK
WARP
NORTH
OFFLINE
STATUS
CRAFT
STILL
PROOF
SIGNAL
ECHO
PRELUDE
NORTH
OFFLINE
STATUS
STORY
STILL
PROOF
DRIFT
ECHO
PRELUDE
NORTH
OFFLINE

Implementation

02
Cardwall.svelte
<script>
  import Cardwall from '$lib/components/Cardwall/Cardwall.svelte';
</script>
​
<Cardwall density="default" tilesPerRow={8} />
<Cardwall density="sparse" tilesPerRow={6} />
<Cardwall density="dense" tilesPerRow={10} tileWidth={210} tileGap={14} />

Cardwall renders rows of CSS-gradient tiles drifting across a CSS perspective container. A single requestAnimationFrame loop wraps each row offset around its period for a seamless seam, and SSR-deterministic Halton(2,3) sequences pick the gradient + label for each tile so server and client agree byte-for-byte. Click any tile to pin it β€” the rest of the wall keeps drifting.

Logic explainer

03

What Does It Do? (Plain English)

Cardwall is a full-bleed statement section that reads like a slowly-tumbling architectural photo wall. Multiple rows of gradient-billboard tiles drift horizontally at row-specific speeds and alternating directions, all viewed through a CSS perspective so the upper rows tilt forward and the lower rows tilt back. There are no images: every tile is a CSS-gradient panel with a serif label, and the whole composition picks itself deterministically from a Halton sequence so SSR and the hydrated client produce byte-identical output. Click any tile (or activate it with Enter / Space) to pin it; the rest of the wall keeps drifting.

Think of it like the marquee photo wall on an editorial homepage, rebuilt as a fully portable Svelte 5 component with zero image assets and zero RNG seeds.

How It Works (Pseudo-Code)

state:
  rows           = buildRows(density, tilesPerRow)    // pure function β€” deterministic
  trackEls       = bound DOM ref per row
  pinned         = null | tile palette
  reducedMotion  = boolean
  rafId          = pending RAF handle
  startTime      = performance now at first tick

on mount:
  reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches
  if not reducedMotion:
    rafId = requestAnimationFrame(tick)

tick(now):
  t = (now βˆ’ startTime) / 1000                        // seconds
  for each row in rows:
    offset = rowOffset(t, period, row.speed, row.dir)
    trackEls[r].style.transform = `translate3d(${-offset}px, 0, 0)`
  rafId = requestAnimationFrame(tick)

events:
  on tile click / Enter / Space:
    if pinned matches this tile: pinned = null      // toggle off
    else:                         pinned = tile     // pin this one

on destroy:
  cancelAnimationFrame(rafId)

The maths is split into a pure-helpers module (Cardwall/types.ts) with no DOM dependencies, so the whole drift / wrap / perspective pipeline is unit-testable without rendering anything.

The Core Concept: Seamless Marquee with a Twin Track

The trick that makes the rows drift forever without seams is rendering each row's tile sequence twice, side by side, and translating both copies in lockstep. When the first copy slides off-screen left, the second copy is already filling in from the right at the same X position the first copy occupied moments before β€” there's nothing to see at the seam because the seam visually never appears.

row inner element renders the tile sequence twice:

  [TILE TILE TILE TILE TILE TILE TILE TILE][TILE TILE TILE TILE TILE TILE TILE TILE]
  ┃ ──────────── copy A ──────────────── ┃ ──────────── copy B ──────────────── ┃

  translate3d(-offset, 0, 0)
       offset wraps inside [0, period)
       period = (tileWidth + tileGap) Γ— tilesPerRow

  when offset wraps from period β†’ 0 (or vice versa for dir = -1):
    copy A snaps back into the same on-screen position copy B was just at
    the snap is invisible because the tile sequences are identical

The wrap is implemented in rowOffset(t, period, speed, dir):

raw = (t * speed * dir) mod period
if raw === 0: return 0           // normalise -0
if raw <  0: return raw + period // always inside [0, period)
return raw

Two subtle moves:

  1. Always-positive return. (-foo) mod period in JavaScript returns negatives, which means the consumer would have to sign-juggle when applying translateX. Wrapping into [0, period) lets the consumer always write translate3d(${-offset}px, 0, 0) β€” correct for both directions.
  2. -0 normalisation. JavaScript distinguishes 0 and -0; 0 * -1 is -0. The helper coerces to +0 so consumers don't accidentally end up with subtly weird transform strings during testing.

Perspective Tilt: Mapping Row Index to Camera

Each row gets a single CSS transform combining translateY, rotateX, and scale:

perspectiveTransform(rowIdx, totalRows):
  mid    = (totalRows βˆ’ 1) / 2
  rel    = (rowIdx βˆ’ mid) / mid                  // [-1, 1] across rows
  tilt   = -rel Γ— 14                              // Β±14Β° rotateX
  scale  = 1 βˆ’ abs(rel) Γ— 0.08                    // 0.92 .. 1.00
  ty     = rel Γ— 6                                // Β±6 px Y nudge
  return `translateY(${ty}px) rotateX(${tilt}deg) scale(${scale})`
row index    rel       tilt       scale     ty
─────────────────────────────────────────────────
   0       -1.00      +14.0Β°      0.92    -6 px      (top β€” tilts forward)
   1       -0.50       +7.0Β°      0.96    -3 px
   2 (mid)  0.00        0.0Β°      1.00     0 px      (camera plane)
   3       +0.50       -7.0Β°      0.96    +3 px
   4       +1.00      -14.0Β°      0.92    +6 px      (bottom β€” tilts back)

The wall lives inside a CSS perspective: 1400px container with transform-style: preserve-3d, so the per-row tilts compose with the camera projection rather than acting as flat 2D rotations. Without preserve-3d, you'd see the rows rotate in their own plane and the depth illusion would collapse.

Deterministic Palette Selection: Halton Sequence

The palette and label of each tile come from a Halton(2, 3) low-discrepancy sequence indexed by (rowIdx Γ— 100) + tileIdx. Halton sequences are quasi-random β€” they look uniformly distributed, but the same input always returns the same output. This solves the SSR-vs-client hydration problem: a server render and a client render of the same (density, tilesPerRow) will pick the same palette for every tile, so React-style hydration mismatches never happen.

halton(i, base):
  f = 1; r = 0; n = i
  while n > 0:
    f /= base
    r += f Γ— (n mod base)
    n  = floor(n / base)
  return r                          // float in [0, 1)

pickTilePalette(seed):
  h2 = halton(seed + 1, 2)          // base 2 β€” picks palette
  h3 = halton(seed + 1, 3)          // base 3 β€” picks label
  palette = TILE_PALETTES[floor(h2 Γ— len) % len]
  label   = TILE_LABELS  [floor(h3 Γ— len) % len]
  return { ...palette, label }

Two Halton bases (2 and 3 β€” co-prime β€” is the canonical choice) ensure palette and label are statistically independent: a "STORY" tile won't always be teal, an amber tile won't always say "DRIFT". The result is visually rich variety from a tiny deterministic pipeline.

State Flow Diagram

                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚  buildRows(density,  β”‚
                    β”‚    tilesPerRow)      β”‚
                    β”‚  β†’ deterministic     β”‚
                    β”‚    palette + labels  β”‚
                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                               β”‚
                               β–Ό
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚  on mount            β”‚
                    β”‚  reducedMotion?      β”‚
                    β””β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                       β”‚
       β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
       β”‚ false                          β”‚ true
       β–Ό                                β–Ό
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚ DRIFTING         β”‚         β”‚ STATIC           β”‚
  β”‚ rAF loop writes  β”‚         β”‚ tracks at        β”‚
  β”‚ translate3d to   β”‚         β”‚ translate3d(0,0) β”‚
  β”‚ each row track   β”‚         β”‚ no rAF loop      β”‚
  β”‚ each frame       β”‚         β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           β”‚
   click/Enter/Space on tile
           β”‚
           β–Ό
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚ PINNED           β”‚
  β”‚ pinned = tile    β”‚
  β”‚ readout          β”‚
  β”‚ announces label  β”‚
  β”‚ (aria-live)      β”‚
  β”‚                  β”‚
  β”‚ tracks keep      β”‚
  β”‚ drifting         β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
           β”‚
   click same tile
           β”‚
           β–Ό
  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
  β”‚ pinned = null    β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Props Reference

Prop Type Default Description
density 'sparse' | 'default' | 'dense' 'default' Number of rows: 3 / 5 / 7.
tilesPerRow number 8 Tiles per row before the seamless duplicate.
tileWidth number 220 Tile width in px. Tile height is 0.62 Γ— width (golden-ratio-ish).
tileGap number 16 Horizontal gap between adjacent tiles in a row, in px.
class string '' Extra classes appended to the .cw-wall wrapper.

Edge Cases

Situation Behaviour
User has prefers-reduced-motion: reduce The rAF loop never starts; tracks rest at translate3d(0, 0, 0). The wall reads as a static composition. Pin interaction still works.
tilesPerRow = 0 or negative period becomes 0; rowOffset returns 0 unconditionally; the wall renders empty rows. The component does not throw.
Window resized while drifting The rAF loop keeps writing the same transform values; the rows reflow naturally because the tiles are inline-flex. No reset needed.
Same tile palette appears twice in adjacent rows Possible β€” Halton(2, 3) is quasi-random but not anti-aliased. The eye reads it as a coincidence rather than a bug.
User pins a tile in copy A, then copy A drifts off-screen The same palette appears in copy B; the pin "follows" because the comparison is by palette content (from + label), not by DOM identity.
SSR with no window isReducedMotion() returns false in non-DOM environments; buildRows is pure; the wall renders to HTML correctly during SSR and hydrates without mismatch.
Very dense configuration (density="dense", tilesPerRow=20) DOM contains 7 Γ— 20 Γ— 2 = 280 tile elements. Acceptable on most hardware; lower tilesPerRow if the target device is constrained.
tileWidth set to a tiny value (e.g. 40) Period shrinks to ~448 px; the wrap interval becomes obvious because tile patterns repeat quickly. The component still works; the visual just looks less varied.

Dependencies

  • Svelte 5.x β€” $state, $derived, $props, bind:this for the row track refs, and onMount/onDestroy for the rAF lifecycle.
  • Zero external dependencies β€” pure CSS gradients (no images, no SVG sprites), pure CSS perspective for tilt, single rAF loop for drift.

File Structure

src/lib/components/Cardwall/Cardwall.svelte         # wall + rAF lifecycle
src/lib/components/Cardwall/CardwallTile.svelte     # one tile (gradient + label + pin button)
src/lib/components/Cardwall/types.ts                # pure helpers (rowOffset, perspectiveTransform, halton, buildRows)
src/lib/components/Cardwall.md                      # this file
src/lib/components/Cardwall.test.ts                 # vitest unit tests (helpers + render)
src/routes/cardwall/+page.svelte                    # demo page

API

04
PropTypeDefaultDescription
density'sparse' | 'default' | 'dense''default'Number of rows: 3 / 5 / 7.
tilesPerRownumber8Tiles before the row repeats (rendered twice for seamless drift).
tileWidthnumber220Tile width in pixels.
tileGapnumber16Gap between tiles in pixels.