WOBBLE MART
A fictional cartoon goods shop, built as a front-end craft demo — hand-drawn SVG, a real-time WebGL2 shader, springy motion, and a genuine accessibility & performance budget.
View the live demo →Performance
Accessibility
used
The brief
Build a shop landing page that feels like a real cartoon brand hired a design studio — playful, consistent, and genuinely fun to move through — while staying an honest demo: nothing pretends to work when it can't.
Tech stack
- Vite 8 + TypeScript 7 (strict). Module-split source; the shipped bundle is minified with source maps withheld.
- Raw WebGL2 (GLSL ES 300), no Three.js. A single full-screen fragment shader draws the hero background.
- GSAP ScrollTrigger for the scroll-scrubbed interstitial, Lenis for inertial smooth scrolling.
- Hand-authored inline SVG for every product, sticker and icon — no image files, no icon font.
- Playwright + axe-core for regression tests, Lighthouse CI for the performance budget, GitHub Actions to run both.
Why WebGL2 for the hero
The hero background is a cel-shaded "goo" of blobs that merge and split, tinted in the brand palette, with a comic halftone texture and a pointer-reactive blob. That look needs per-pixel work — smooth metaball/SDF unions, flat colour quantisation, and crisp anti-aliased ink outlines — which is exactly what a fragment shader does cheaply on the GPU.
I used raw WebGL2 rather than a library because the scene is a single quad with one shader: pulling in Three.js would add weight for machinery this effect never uses. WebGL2 (94.7% global support at build time) also gives fwidth() and GLSL ES 300 without extension juggling. Every device that can't create a WebGL2 context falls back to a pure-CSS blob background, so nothing breaks — the page just degrades quietly.
Responsive approach
- Fluid type with
clamp()so headlines breathe on desktop and never orphan-wrap at 390px. - Grids collapse 4→3→2 columns; the sticky header drops its nav links to a scrollable second row on mobile instead of hiding them.
- No horizontal scroll at any width — guarded with
overflow-x:clipon<html>(nothiddenon<body>, which would have quietly broken the sticky header). - The shader renders at a capped device-pixel-ratio so retina phones don't pay for 3× pixels.
Accessibility
- WCAG 2.2 AA contrast across badges, cart count and stat figures — each verified by in-browser ratio measurement, not by eye.
- Keyboard-complete: skip link, visible
:focus-visiblerings, a native<dialog>cart with focus trapping and Escape-to-close, and focus that follows in-page anchor jumps. - Semantic landmarks, labelled controls, and
role="img"with alt text on decorative SVG that carries meaning. - prefers-reduced-motion disables the marquee, letter bounce, confetti and smooth-scroll, and freezes the shader to a single static frame. This branch is exercised by an automated test, not just written.
- axe-core reports zero serious or critical violations in CI.
Performance
- Shipped JS is about 55 KB gzipped (including GSAP + Lenis); CSS ~5.5 KB; HTML ~9 KB gzipped.
- Lighthouse (desktop): Performance 99–100, Accessibility 100, Best Practices 100 across repeated runs; first contentful paint well under a second, cumulative layout shift ≤ 0.03, total blocking time 0 ms.
- The shader's animation loop is gated by an IntersectionObserver and
visibilitychange, so it stops when the hero is off-screen or the tab is hidden. - No layout shift: every product illustration reserves its box via
aspect-ratio, and fonts usedisplay:swap.
Hard parts & how I solved them
The sticky header silently stopped sticking
overflow-x:hidden on <body> makes the computed overflow-y resolve to auto, turning the body into the scroll container — so position:sticky stuck to the body's scrollport, not the viewport. Fix: drop the body rule and rely on overflow-x:clip on <html>, which contains horizontal bleed without creating a scroll container.
Blobs merged into muddy colours
My first shader averaged each pixel's colour across all blobs, which browned the overlaps. I switched to snapping every pixel to its nearest brand colour and unioning the shapes with a smooth-minimum distance field — flat cartoon regions with clean, organic seams.
A "type-safe" tweak that could freeze the whole page
Asserting a canvas 2D context as non-null at module top level meant that if the browser ever refused the context, every init function after it would never run. An independent review caught it; I changed it to fail softly, disabling just the confetti, matching how the shader already handles the same class of failure.
"Resolution-independent" wasn't quite the right claim
I initially said fwidth() keeps the outline a fixed thickness at any resolution. Measuring pixel widths proved the outline scales with canvas size by design; what fwidth() actually guarantees is that the anti-aliased edge stays a couple of pixels wide and never jaggies. I corrected the wording rather than the code.
What I'd do next
- Cross-browser runs on WebKit and Firefox in CI (currently Chromium only).
- A depth-map or cutout-sprite pass to give the hero real parallax.
- If it ever became a real shop: move cart, inventory and checkout server-side — a static page is by definition 100% copyable, so the defensible value has to live where the browser can't see it.
- Persist the demo cart to
localStorageso a refresh keeps it.
Links
Source repository: published privately (front-end source is intentionally not public — see the "what I'd do next" note on copyability). A read-access link can be shared on request.