InkMaps
The guide

How this was built.

InkMaps is a tattoo-shop directory — a site meant to earn organic search traffic and support display advertising, in a niche with a strong, specific visual culture to draw from. It began as a pure design build and now runs on real, location-based listing data. This page is the case study, written so the same process can be repeated on the next brief.

The brief

Build a tattoo-shop directory that showcases web design capability — 3D, motion, custom imagery, and type choices that lean into the niche's own visual language — rather than a generic template with a search bar bolted on. The first build shipped as a finished design system; real shop data was then wired in on top of it, state by state.

The reference material was four photographs of real tattoo parlors: dense flash-sheet walls in red, black, and gold; neon storefront signage at night. Those photos, not a mood board of generic "tattoo aesthetic" references, set the palette and the central visual idea — a glowing neon mark as the hero's centerpiece.

Stack

Next.js 16 (App Router, TypeScript, static export) on Tailwind CSS v4. Motion (motion/react) handles scroll-reveal for the style grid. GSAP + ScrollTrigger handles the pinned sticky-stack of featured shops. React Three Fiber renders the hero's neon Jolly Roger. Icons are Phosphor.

GSAP and Motion are kept in separate components rather than mixed in one tree — both want to own the scroll/frame loop, and combining them inside a single component is how scroll sections end up double-firing.

Palette and type

The palette is lifted directly from the reference photography rather than invented from scratch: a warm near-black (ink) for the base, flash crimson and marquee gold as the two accents (the red-and-gold of a framed flash sheet, the tubing color of storefront neon), bone paper for text on dark ground, and a hot signal pink reserved only for glow effects — it never appears as a static UI color, only as light.

Type mixes four fonts on purpose, each doing one job: UnifrakturCook, a bold blackletter, is used only for the wordmark and one hero accent word — the same lettering tradition tattoo flash itself draws from — never for running text. Bebas Neue, a tall condensed poster face, carries display headings and reads like storefront signage. Inter is body text. Space Mono is reserved for small uppercase labels (nav links, eyebrows, tags).

The hero

The hero layers three things: a generated night-street photograph as a dimmed background plate, a React Three Fiber scene rendering a stylized neon Jolly Roger, and the page copy on top. The skull outline is a TubeGeometry swept along a closedCatmullRomCurve3 — traced from a deliberate eleven-point silhouette, not freehand: a round cranium, temples bulging out at the eye line, curving in to a flat, square-cornered jaw. Two torus eye sockets and a small triangular nose (also a closed tube curve) sit inside it. The crossbones are two identical shaft-plus-sphere-knob groups, each built in its own local frame so it can be rotated 45° in either direction as one rigid piece. The whole group renders three times — a saturated red core plus two progressively larger, fainter, additively-blended copies in a warmer red behind it — faking a soft bloom/glow without a full postprocessing pipeline.

Getting the silhouette right took three attempts, each only evaluated by actually reading a screenshot rather than trusting the code. A torus cranium with a separate half-torus jaw (round head, distinct jaw floating below it) read as a cartoon owl once eyes and a nose were added. Replacing that with a freehand smooth curve tapering to a single point at the chin read as a heart or a hooded ghost instead — a continuous, evenly-curved outline reads as a cute or spooky face almost regardless of what's inside it. What actually worked was using flat, square jaw corners: a skull's silhouette needs an angular jawline, not just a round head, to read as a skull rather than a face.

A per-frame flicker function nudges the core material's brightness with a slow sine wave and occasional sharp dips, the way a real neon tube stutters rather than dimming smoothly. The whole group also rotates slowly. Both the flicker and the rotation are gated behind usePrefersReducedMotion, which reads matchMedia live instead of once, since motion/react's built-in hook was found (on an earlier build in this same design system) to freeze whatever the setting was on first mount and never update again.

The canvas loads through next/dynamic with ssr: false from a small client wrapper, so the Three.js bundle never blocks the initial server-rendered page, and is only mounted on xl: and wider viewports — mobile and tablet get the photo and copy without shipping the WebGL bundle at all, and without the skull competing with the headline for space at in-between widths.

Imagery

The hero atmosphere image was generated for this project through the Higgsfield connector, not pulled from a stock library. The prompt specified no readable text and no visible people — to avoid garbled fake signage and any implied real person. It was converted to WebP and sized to its display width before shipping. Shop listings themselves carry no photos (see the data section for why).

Real data pipeline

Listings are real tattoo shops sourced from public Google Maps business data (via Outscraper exports). A Node script, scripts/build-shops.mjs, reads every spreadsheet dropped in data/sources/, keeps only operational tattoo businesses, de-duplicates them by Google place id, and writes a single static shops.json the site builds from. Coverage expands region by region, one state at a time.

Two fields are deliberately dropped: the hotlinked Google photos and Google's own written descriptions. Only factual listing data — name, address, phone, website, hours, rating — is kept and republished, and the directory is browsed by location (state → city → shop) rather than by any subjective classification the source data doesn't reliably provide.

Iteration

The build went through three full review passes after the first working version: a design-and-copy audit, a second pass focused on accessibility and responsiveness (contrast, reduced motion, mobile collapse, focus states), and a final polish and performance pass. Specific fixes from each pass are logged in DECISIONS.md in the repository rather than repeated here, so this page stays a description of the finished system rather than a changelog.

Reusing this approach

For the next niche: let a handful of real reference photos set the palette instead of a generic mood board; pick one piece of technical craft (a shader, a generative icon or pattern system, a real physics-feeling interaction) to be the thing the site is actually about; generate real imagery for the niche rather than stock photography, with explicit constraints in the prompt (no text, no people, whatever the context requires); and if the data is fictional, label it as such everywhere it appears rather than letting the design imply otherwise.