Furniture e-commerce is split between two failure modes. On one end, the IKEA-style listing grid — endless tiles, micro-photos, sort-by-price-ascending. On the other end, the design-gallery cosplay — full-bleed hero of a chair on a beach, no price, no add-to-cart for three scrolls. Norraform sits in the gap.
It's the fourth template I shipped this week. The constraints are the same as the other four: no build step, no Vite, no React-Router. Static HTML per page, React 18 UMD, Tailwind via CDN.
The PDP gallery bug
The product detail page (PDP) opens with a stacked gallery: one large hero image, a thumb row below, click a thumb to swap the hero. The component fades the hero from opacity: 0 to opacity: 1 on swap for a soft transition.
It didn't work. Every image stayed visible at once, stacked on top of each other, and the last-added one always won.
The cause was in the shared Img component — a thin wrapper around <img> that handles graceful fallbacks and a "blur-up" reveal:
style={{ opacity: loaded ? 1 : 0, transition: 'opacity 320ms' }}
The wrapper unconditionally set its own opacity to 1 once the image loaded, overriding the parent's opacity: 0 on the inactive slides. Every image in the gallery loaded → every image set itself to opacity 1 → every image was visible.
Fix:
style={{ opacity: loaded ? (style.opacity ?? 1) : 0 }}
The wrapper still owns the blur-up reveal, but if the parent passes an explicit opacity in the style prop, that wins. Two characters of code; one entire feature unbroken.
The hero
The hero is a full-bleed lifestyle photo (warm linen sofa, dried branch in a stoneware vase, soft afternoon light) under a calm overlay. The headline pairs a tight serif ("Built to last,") with an italic phrase ("made to love.") under a hand-drawn gold underline.
Above the headline: a small kicker — "Norraform — Est. Stockholm". Below: a short subtitle ("Furniture made from honest materials, designed to live with you for decades — not seasons. Quietly built in the North.") and two CTAs ("Shop the collection" filled, "Our story" minimal with arrow).
The hero rotates between three lifestyle photos via a slider dot row at the bottom. Each transition is a 700ms cross-fade — no slide, no scale. Cross-fade reads as a magazine spread, slide reads as a carousel ad.
Reveal animation
Originally a 1s rise-up with translateY(2rem) on every section. On desktop it was lovely. On mobile it read as: every section blinks like an eye as you scroll past. Cut to 0.45s and translateY(8px) — same intent, no twitch.
The animation patterns I kept
Marquee mask. The materials ticker at the top of the page is an infinite horizontal scroll. The naked ticker has a hard edge — words appear and disappear at the viewport border. A linear-gradient mask on the container fades the leftmost and rightmost 9% to transparent. Words materialize and dissolve at the edges instead of popping.
Lookbook hotspots. The lookbook page has pulsing dots on a wide lifestyle photo — tap one, a small product card slides up with the product name, price, and add-to-cart. The dot itself is a 1px center with a 6px outer ring that pulses to 12px and 0 opacity. The product card uses backdrop-blur and translate-y-3 opacity-0 → translate-y-0 opacity-100 on hover/tap.
Quickview modal. Click any product card on the shop page → quickview opens in a 450ms ease modal with opacity-0 translate-y-5 scale-[0.985] → opacity-100 translate-y-0 scale-100. Backdrop blurs the page behind. Close on outside-click or escape.
What's the rest of the site
product.html, article.html, account.html, cart.html, checkout.html, wishlist.html, lookbook.html, about.html — every commerce surface a furniture catalog needs. None of it lives behind a SPA router. Each page is its own HTML file that mounts a React tree. Slower to navigate? By maybe 200ms. Easier to debug, deploy, and replace one page without touching the others? Yes.
Live
norraform-template-04.netlify.app — open the shop page, click a product, watch the quickview transition.
