← Back to the shop
PORTFOLIO CASE STUDY

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 →
100Lighthouse
Performance
100Lighthouse
Accessibility
0photographs
used
~55KBJS, gzipped

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.

Goal
Show front-end craft: visual identity, motion, real-time graphics, accessibility, performance.
Audience
Recruiters and clients evaluating front-end / creative-dev work; secondarily, curious visitors.
My role
Everything — concept, copy, illustration, design system, engineering, testing, deployment.
Type
Fictional brand. All products, prices, reviews and stats are invented and labeled as such.

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:clip on <html> (not hidden on <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-visible rings, 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 use display: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 localStorage so 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.