← All work

Case study // 11

EatBest

A neo-brutalist restaurant site with a WebGL scene on the 404.

Role
Engineer
Year
2026
Category
Web
Status
Live
Visit live Read the source
  • Next.js 16
  • React 19
  • Tailwind CSS v4
  • Framer Motion
  • React Three Fiber
  • TypeScript

// CONTEXT

The problem

EatBest is a five-route marketing site for a food delivery brand — home, about, menu, blog and contact — built front end only, with no ordering backend, no CMS and no payment step behind it. The pages themselves are well-trodden ground. The actual brief was the presentation layer: a neo-brutalist visual language of 4px black borders, hard offset shadows and Titan One display type held consistently across every route, and a scroll where sections pin, fold and slide over one another rather than simply passing by.

// CONSTRAINTS

What made it hard

  • No backend and no CMS, yet every price, review, article and form field still has to be editable by someone who is not going to go hunting through JSX for it.
  • The identity is almost entirely motion — a preloader, a six-second hero carousel, pinned section transitions and a 3D 404 — and all of it runs on the client, so every effect is paid for on the visitor’s device.
  • Neo-brutalism offers nowhere to hide. There are no gradients or blurs softening the edges, so a 1px misalignment on a hard black border is visible at a glance.
  • One layout from a 360px phone to a wide desktop, with a fixed header sitting over a full-height hero — the exact arrangement most likely to clip its own content.

// DECISIONS

What I chose, and what I didn't

  1. 01

    Put every string and image path in one typed content module: a 129-line data.ts describing ten sections, against 17 interfaces in types.ts.

    Why

    Five routes share the same nav, menu, testimonials and footer. With the copy inline, changing a price or a heading means finding the same sentence in three components and missing the fourth. One module makes content a data edit, and the interfaces mean a missing field is a build error rather than an empty div in production.

    Instead of

    Writing the copy straight into the components, which is faster for the first page and turns every subsequent copy change into a search across the codebase.

  2. 02

    Build a single SectionReveal primitive with six view modes — recede, zoom, still, flip, blur and fold — and compose the choreography from the page.

    Why

    The home page stacks seven sections and overlaps them with negative margins and an explicit z-index ladder. One component measures its own content with a ResizeObserver, maps scroll progress through a spring, and takes the transition as a prop, so a section changing from a fold to a flip is a one-word edit. It also keeps the maths in one place, where the sticky-height calculation can be fixed once.

    Instead of

    Per-section scroll listeners, which is six near-copies of the same transform maths drifting out of sync with each other.

  3. 03

    Make the 404 a real React Three Fiber scene, but load it dynamically with ssr: false behind an animated fallback.

    Why

    three.js and drei are by a wide margin the heaviest dependencies in the project, and they exist for the one route nobody intends to visit. A dynamic import keeps them out of every other page’s bundle, and the pulsing 404 that renders while the canvas loads means a slow device still gets a usable error page instead of an empty screen.

    Instead of

    Importing the scene normally, which taxes the home page with a WebGL bundle to pay for a joke on a route most visitors never see.

  4. 04

    Seed the scene’s randomness with a deterministic PRNG and draw the 1,200 background particles as two instanced meshes.

    Why

    An unseeded Math.random reshuffles the confetti on every re-render, which breaks React’s purity rules and reads as flicker. mulberry32 with a fixed seed gives the same scattered-by-hand look every render. Instancing splits the particles into pills and dots and animates them by writing matrices into two buffers, rather than mounting 1,200 objects. The donut’s 110 sprinkles are placed the same way, positioned by the torus’s parametric normal and rotated by quaternion so they sit on the icing instead of hovering near it.

    Instead of

    Plain Math.random and a mesh per particle, which looks identical in a screenshot and stutters the moment the scene animates.

  5. 05

    Split the preloader’s dismissal and its scroll lock into separate effects, each with its own cleanup.

    Why

    Dismissing from inside the setProgress updater looks tidy, but React may invoke an updater more than once, and each extra invocation scheduled another timer. Restoring body overflow in a cleanup matters just as much: without it, an unmount mid-load leaves the page permanently unscrollable with no visible cause.

    Instead of

    One effect handling progress, dismissal and the scroll lock together — which works in a demo and fails under Strict Mode’s double invocation.

// OUTCOME

What came of it

  • Five routes and a 404, with all copy and imagery driven from a single 129-line content module typed by 17 interfaces.
  • 20 components across roughly 3,400 lines of TypeScript, with one scroll primitive covering every pinned transition on the site.
  • A 404 built from extruded Text3D digits, a procedurally assembled donut carrying 110 surface-aligned sprinkles, and 1,200 instanced particles — all code-split away from the rest of the site.
  • Layout defects found and fixed on real viewports rather than in the design: a hero that clipped its own badge under the fixed header at h-screen, and a desktop nav that clipped the order button until it was held back to the lg breakpoint.

// REFLECTION

What I'd do differently

There is no prefers-reduced-motion path anywhere in this site, which is a real omission on a build that is almost entirely motion — a preloader that locks scrolling, a carousel that advances itself, sections that fold away and a scene that never stops spinning. Framer Motion already exposes useReducedMotion; wiring it in from the first component would have cost nothing, and retrofitting it now means touching every animated file. Two smaller shortcuts are still standing for the same reason: images.unoptimized turns off Next’s image pipeline, so the full-weight PNG food shots ship as authored, and the mock menu leans on real trademarks for its product names — fine in a portfolio piece, and the first thing that would have to go before this fronted an actual brand.