053

Jewellery Template

£20 or the whole Vault, £99

Two-page shop for any single-object luxury brand where the photograph carries the sale: jewellery, watches, ceramics, glass. Sweep a raking light across one piece to move its exposure, specular streak, shadow and colour temperature at once, then a twelve-piece shop with a Flip category filter and a selection drawer.

ScrollTriggerFlip Lenis advanced
16 more details
pointer-trackingcss-mask-revealblend-modequickToticker-loopparallaxscrubscroll-revealstaggerflip-filterform-validationkeyboard-navigationdialog-animationclick-togglevalue-transitionmicro-interaction

This template reads better at your own screen size than in the frame below:

Updated

About this template

A complete two page site for Halvard, a fictional independent fine jewellery house. It is deliberately cold: a silver-grey ground, smoke ink, a single malachite accent for the inverted sections, and a raised surface that is lighter than the ground rather than darker. There is no serif anywhere and no cream, beige, brass or gold in the CSS, so the only warm colour on the page is the metal inside the photographs.

Read the full template overview

The hero is a vitrine. One macro photograph of a ring runs at more than half the width of the window, cropped by the top and right edges, with a CSS cast shadow grounding it and the second line of the headline running underneath it. Below that, a section given over entirely to one interaction: a raking light you sweep across a single piece, which changes its exposure, throws a specular streak ahead of itself, swings a cast shadow the other way and warms the lit region as it passes.

The second page is a real shop: twelve pieces in a regular four column index with working category chips that reflow the grid with GSAP Flip, and every piece visible in the markup so the page is a complete product list before any script runs. The home page keeps a packed five-piece teaser that links through to it.

Both pages sell. Every piece carries an add line under its caption, the nav carries a live count, and the drawer that slides out is a jeweller's docket: name and line total on one baseline, spec, unit price and quantity under it, then a subtotal that counts to its new figure. One selection is shared between the two pages through localStorage. It is a front end cart and it says so: the checkout button is a marked stub that takes no payment, and the README documents where to wire your own provider.

The rest is an asymmetric five-piece collection grid at four different scales, an atelier section where the photograph runs to the edge of the window and lifts above the section seam, a commission enquiry on a committed malachite field with a real validated form, and a hairline footer.

Making it yours is a four-value edit. The token block at the top of style.css holds the ground, the ink, the accent and the raised surface with their RGB triples; every other colour on the page is one of those four at another opacity. Photographs are bundled optimised WebP derivatives you can swap file for file, and every section is independent, so any of them can be deleted without breaking the others.

Jewellery Template - website template preview

What's included

17 items
  • Complete deployable two page jewellery site: fixed nav, vitrine hero, signature light section, collection teaser, atelier, commission form and footer, plus a full shop page
  • The rake light: a pointer-driven raking light with four channels answering one input, tuned from a single named constants block
  • Fast pointer movement overshoots the cast shadow past its resting angle and smears the specular streak, then damps back into a slow idle drift
  • Coarse pointers get the same interaction driven by scroll progress through the section, not a switched-off placeholder
  • Arrow-key control of the light with a focus stop that only exists when the script has loaded
  • Two stacked copies of one photograph do the exposure reveal, so the section needs a single image file
  • Second page, collection.html, with a twelve piece shop grid, working category filter and a shared stylesheet and script
  • Every product photograph blended into the ground with mix-blend-mode and a per-image lift, so no plate has a visible rectangle
  • Asymmetric five-piece collection grid at four scales, with captions below the photographs and a wide piece breaking the frame
  • Real commission form with inline validation, described errors, and every input, label, placeholder and focus ring measured against the malachite field
  • Four-token palette with RGB triples behind every translucent value, and the measured contrast table recorded in the stylesheet
  • Reduced-motion, no-JavaScript and blocked-CDN states that each leave a complete, designed, fully readable page
  • Optional Lenis smooth scrolling on the same clock as ScrollTrigger, switchable per page or per URL
  • A front-end selection drawer on both pages: add from any piece, change quantities, remove lines, read a counting subtotal, with the selection held in localStorage and shared between the two pages
  • Honest about what it is: the checkout button is a marked stub that takes no payment and places no order, and the README documents exactly where to wire Shopify, Stripe Checkout or Snipcart
  • The cart control, the add lines and the drawer are built by the script, so with JavaScript off both pages stay complete catalogues with no dead buying controls
  • The drawer is a real dialog: focus moves in and is trapped, Escape closes it, focus returns to the control that opened it, and the rest of the page is inert while it is open

Perfect for

5 use cases
  • Independent jewellers and goldsmiths selling made-to-order and commission work
  • Watchmakers and other single-object luxury brands where the product photograph carries the sale
  • Ceramics, glass and metalwork studios wanting a cold, gallery-like presentation
  • Perfume, eyewear and small-batch accessory houses with a short numbered collection
  • Any brand-and-commerce landing page that needs one memorable interaction rather than a page of them

How it works

7 sections

The vitrine hero

The hero has no container and no card. The photograph is positioned against a frame that stops growing past 108rem, so the crop and the overlap hold the same proportions on a large monitor as on a laptop. Because the piece is square, its height is restated once as a custom property that both the image and its CSS cast shadow read. The shadow deliberately sits behind the headline rather than across it: a shadow falling over the type looks good and costs the headline four points of contrast.

The rake light

One photograph is stacked twice. The lower copy is graded down to near darkness and the upper copy is graded up, then revealed through a soft linear band whose position is a single custom property. Because both layers are the same picture, a wide feather reads as light falling away rather than as two images averaging into mush, but the band keeps a solid core so the lit region still has an edge.

Four channels, one input

Pointer x eases the light position, and everything else is derived from it each frame: a smoothed signed speed, a shadow that trails behind rather than tracking exactly, and a smear that relaxes on its own. Speed throws the shadow past its resting angle and elongates the streak; distance from the middle lengthens the shadow and cools the lit region. Every number is in one constants block at the top of the script.

Touch, keyboard and rest

A coarse pointer drives the same light from scroll progress through the section, with the smear channel dropped because there is no whip gesture to smear. Arrow keys step the light and get a focus stop that the script adds and removes, so it never appears on a page whose JavaScript failed. When the pointer leaves, the light eases out of where it was left and into a slow idle drift over about a second and a half rather than snapping home.

The shop page

collection.html reuses the same stylesheet, script and head gate as the home page, and every initialiser returns early when its markup is missing, so one script serves both. The category chips hide and show cards, set aria-pressed, and reflow the grid with Flip. The grid's height is pinned and eased during that reflow, because Flip lifts the surviving cards out of normal flow and the page below would otherwise lurch on every press.

The cart drawer

Add lines under every piece on both pages write to one selection in localStorage, so a piece added on the home page teaser and the same piece on the collection page are one line item rather than two. The drawer lists each line as a jeweller's docket, name and total on one baseline with the spec, unit price and quantity under it, and the subtotal counts to its new figure instead of cutting to it. Prices are read out of the card's own caption and held as whole pence, so the drawer cannot disagree with the card and a subtotal never picks up a rounding error. It is front end only: the checkout button is a stub that says so, on the panel and again when pressed, and the README documents where to put your own provider.

Without JavaScript

The gating class is added synchronously in the head so reveal start states apply before first paint, and the script removes it when GSAP is missing, so a blocked CDN degrades to the plain document rather than hiding content forever. The light position and warmth are ordinary CSS custom properties with sensible initial values, so with scripting off the piece is already lit from the right. Under prefers-reduced-motion the same section freezes at a flattering angle with the shadow at rest, and the form falls back to the browser's own validation. The cart is built the same way round: the control, the add lines and the whole drawer are created by the script rather than written into the pages, so a visitor with scripting off gets a complete catalogue with no buying chrome instead of buttons that cannot work.

Plugins ScrollTrigger, Flip
Difficulty Advanced
Smooth scroll Lenis integration
Includes HTML + JS + CSS source, documentation, AI setup prompt, lifetime updates

Lighthouse, as measured

Google Lighthouse on this template's demo (2 pages, lowest score shown), from the latest scan. A measurement of the demo as shipped, not a promise for your page.

Accessibility
100
Best practices
100

No performance score, on purpose. That figure depends on how you deploy: your server's compression and caching, your CDN, the connection and the device doing the test, none of which the code controls. The same page can score very differently on two consecutive runs, so measure it where it will live.

Questions about this template

Does the cart take payment?

No, and the template is explicit about it: the checkout button is a marked stub that takes no payment and places no order, and the README documents exactly where to wire Shopify, Stripe Checkout or Snipcart. The drawer itself is complete, with quantities, removal, a counting subtotal and localStorage shared across both pages.

What is the raking light doing?

Four channels answer one pointer input at once: exposure, the specular streak, the cast shadow and colour temperature. Move fast and the shadow overshoots past its resting angle and the streak smears, then damps back into a slow idle drift. It is tuned from one named constants block.

How many photographs do I need for the light section?

One. Two stacked copies of the same photograph do the exposure reveal, so the signature section needs a single image file. The shop page is where the photography demand is real: twelve pieces, each blended into the ground with mix-blend-mode and a per-image lift.

What happens on a phone, with no cursor to rake?

The same interaction, driven by scroll progress through the section rather than a switched-off placeholder. Keyboard users get arrow-key control of the light, via a focus stop that only exists once the script has loaded, so there is never a dead tab stop.

Is it one page or two?

Two: the main site and collection.html, a twelve-piece shop grid with a Flip-powered category filter, sharing one stylesheet, one script and the selection drawer. With JavaScript off both pages stay complete catalogues with no dead buying controls, because the cart UI is built by the script rather than sitting inert in the markup.

Does the commission enquiry reach anyone?

No. The form collects a piece type, a deadline and a description, and confirms on screen, but nothing is sent anywhere. Give it a real endpoint before you launch. For commissions specifically, route it somewhere that keeps a thread rather than a bare inbox: the conversation after a commission enquiry usually runs for weeks.

Paid template

Purchase to unlock the code.

Buying Jewellery Template opens the HTML, CSS and JavaScript source, the full documentation, an AI setup prompt for your editor, and every update we ship to it. Standard license: unlimited personal and commercial projects. Every paid product is tested for desktop and mobile layouts, reduced-motion handling, animation cleanup and keyboard controls wherever there is something to operate.

£20 Standard license, unlimited projects

Browse free effects

Or the whole library: the Vault, £99, one payment.

Documentation

Quick Start

1. Upload the folder. It is static: index.html, collection.html, assets/style.css, assets/script.js, assets/img/. No build step, no dependencies to install. Both pages share one stylesheet and one script, and every section initialiser returns early when its markup is absent, so each page silently skips what it does not have.

2. The three script tags at the bottom of index.html load GSAP, ScrollTrigger and Lenis from a CDN. Leave them as they are, or self-host the files and change the paths.

Code snippet omitted: it ships with the download.

3. Keep this line in <head>, above the stylesheet:

Code snippet omitted: it ships with the download.

It runs before the first paint so that reveal start states apply without a flash. assets/script.js removes the class again if GSAP fails to load, which is what makes a blocked CDN degrade to a plain readable document instead of a page of hidden content. Moving this into the body reintroduces the flash.

4. Replace the copy and the photographs. Every section is independent: the script skips any section whose markup is not there, so you can delete whole sections without touching the JavaScript.

Sections and their options

Nav

Four links, a wordmark and the selection control on one 68px row. --nav-h in the token block sets the height and the scroll offset for the anchor links together; change it in one place. There is deliberately no call to action in the nav beyond the selection count, because a house that takes commissions does not need one above its own name. The row stays on one line down to 320px, which is what the tracking steps at the 560, 400 and 345px breakpoints are for: lengthen a label and check those.

1. The hero, "the vitrine"

The photograph is 56% of the frame, pushed off the right edge and up through the top edge so the frame crops it on two sides, and the indented headline line runs underneath it.

To change Where
How far the piece bleeds off the top --piece-top on .hero-frame
How wide the piece is width on .hero-piece, and the 56vw inside --piece-h to match
How far the second headline line runs under it margin-left on .hero-line--indent
The cast shadow's angle and softness transform and filter on .hero-cast

The piece is square, and top cannot take a percentage of width, so its height is restated once as --piece-h and both the image and the shadow read it. If you change the image's width you must change that value too.

The headline lines are white-space: nowrap, so they stay lines. That is what --step-display is clamped for at each breakpoint; if you write a longer headline, bring the clamp down with it or the line will be clipped by its mask.

2. The rake light

Two copies of one photograph, stacked. The lower copy is graded down to near darkness, the upper copy is graded up and revealed through a soft band that follows the light. Every number the interaction is tuned by is in one RAKE block at the top of assets/script.js:

Key Default What it does
follow 0.42 Seconds the light takes to reach the pointer
shadowLag 0.085 How far the shadow trails the light. Lower is more lag
swing 54 Degrees the shadow swings, counter to the light
stretch 0.9 Extra shadow length once the light is off centre
overshoot 16 Degrees per unit of speed, thrown past the resting angle
speedCap 6 Speed is clamped here, so a flick cannot spin the shadow
smearGain / smearMax 0.55 / 3.2 How far a fast sweep elongates the streak, and the cap
lead / leadMax 26 / 130 How far ahead of the light the streak rides, in px
idleAmp / idleSpeed 0.17 / 0.42 How far and how fast the light drifts at rest
settle 1.6 Seconds to blend from the last pointer position into that drift
scrollFrom / scrollTo 0.08 / 0.92 Where the scroll-driven version starts and ends

Two things live in CSS rather than in the script:

  • The exposure grade. .rake-img--base and .rake-img--lit are the same file with different filter values. Raise brightness on the lit copy for a harder light, lower brightness on the base copy for a darker room.
  • The softness of the band. --band-core, --band-mid and --band-edge on .rake-img--lit set how wide the lit region is and how far it feathers. Keep a solid core: a band that is feather all the way through loses the edge that makes it read as light rather than as a blur.

The warmth of the lit region comes from sepia() on the lit copy, scaled by --warmth, which the script raises as the light crosses the middle of the piece. That is the only warm value the CSS produces anywhere, and it is deliberately tied to the light rather than painted in.

3. The collection

An asymmetric twelve-column grid at four scales. Piece positions are five explicit grid-column / grid-row rules on .piece--lead through .piece--wide, with the wide piece running past the right edge of the frame. Add or remove a piece by editing those five rules; the grid does not derive itself from the content.

Each plate is a fixed-ratio window with the photograph 12% taller than the window, centred. That overscan is what the drift moves within. data-drift on a plate is the travel in percent of the image height, applied as a scrubbed yPercent. Keep it under about 5 or the photograph will show a gap at one edge as it travels. Remove the attribute to stop a tile drifting.

4. The atelier

An asymmetric split: the photograph runs to the left edge of the window and lifts above the seam with the section before it, set by margin-top on .atelier-media. The oversized numeral is .atelier-numeral, and it is the one place malachite is used as display type on a light surface.

5. Commission

The form validates inline while assets/script.js is running and never submits. To make it real:

  1. Set action on the <form> to your handler, and method to what it expects.
  2. In initForm in assets/script.js, remove the e.preventDefault() in the submit handler, or replace the success block with a fetch to your endpoint.

Without JavaScript the form falls back to the browser's own validation, since novalidate is only added by the script.

6. Footer

Four columns above a hairline. Nothing in it is wired to anything.

The collection page

collection.html is the second page: a shop index of twelve pieces in a regular four column grid, a filter, and a closing band back to the commission form. The nav on both pages links to both and marks the current one with aria-current="page".

To add a product, copy one <article class="card"> block and change the image, name, spec and price. Set data-category to one of rings, earrings, bands or chains, and add a matching chip to .filter-chips if you introduce a new category. Give it a unique data-piece id as well, which is what the cart stores it under (see Cart below). The count in the bar is derived, not written, so it stays correct.

The filter hides and shows cards, updates each chip's aria-pressed, and reflows the grid with GSAP Flip. Two things about it are deliberate:

  • Without JavaScript every piece is visible. The cards are all in the markup with nothing hidden, so the page is a complete product index before any script runs. The chips are real buttons that simply do nothing until the script wires them up. Nothing is ever unreachable.
  • The grid's height is held during the reflow. Flip lifts the surviving cards out of normal flow, which would otherwise collapse the grid to nothing and make the footer lurch up and snap back on every filter press. The script pins the height and eases it to its new value instead.

Under prefers-reduced-motion the filter still filters; it just swaps instantly with no reflow animation.

collection.html also loads GSAP's Flip plugin, which index.html does not need. Drop that one script tag if you remove the filter.

Cart

Read this before you sell anything with it. The cart is front end only. It keeps the selection in the visitor's own browser, in localStorage, and it talks to nothing: no server, no payment provider, no order. The checkout button is a stub. It says so on the panel, and pressing it says so again. It cannot take money, and it must not be left as it is on a site that is trying to. Wiring it to a real provider is the job below, and it is the only thing between this template and an actual shop.

Nothing in it invents a trust signal either: there is no padlock, no provider logo and no claim about anyone else's basket. If you add those, add them because they became true.

What it is

  • A selection control in the nav on both pages, with a live count. Below 560px the word gives way to the count plate, because the nav is one line at every width and there is no room for both.
  • An add line under every piece, on the five in the home page teaser and on all twelve on the collection page.
  • A slide-out drawer listing each line as a docket: name, spec, unit price, quantity, line total, then a subtotal and the checkout button.
  • One selection across both pages. The same data-piece id on the home page teaser and the collection page is one line item, not two.

The control, the add lines and the drawer are built by the script, not written into the two pages. That is deliberate: with JavaScript off the pages are a complete, readable catalogue with no buying chrome at all, rather than buttons that cannot work. A dead Add button is worse than no Add button.

To add a product

Copy a <article class="card"> on collection.html (or a <figure class="piece"> in the home page teaser) and give it a unique data-piece id:

Code snippet omitted: it ships with the download.

That is all the cart needs. The name, spec and price come out of the card's own caption (.card-name / .card-spec / .card-price, or .piece-name / .piece-spec / .piece-price in the teaser), so the price in the drawer cannot disagree with the price on the card. Prices are parsed by stripping everything that is not a digit or a decimal point, so &pound;6,400 and &pound;1,950.50 both read correctly. Change a price in the markup and any selection already sitting in a visitor's browser is restated the next time they load the page.

Use the same id in both places when a piece appears on both pages.

To wire a real checkout

Everything is in initCart in assets/script.js, under the comment marked CHECKOUT GOES HERE:

Code snippet omitted: it ships with the download.

items at that point is the whole selection, and its shape is the shape held in localStorage under the key halvard-selection:

Code snippet omitted: it ships with the download.

price is in pence, as a whole number, so a subtotal is integer addition and never picks up a rounding error. Multiply by qty for the line, divide by 100 only to display.

Three ways people usually take it from here:

  1. Stripe Checkout. POST items to your own endpoint, create a Checkout Session server side (never with a secret key in this file), then window.location = session.url.
  2. Shopify, or any headless store. Map each id to a variant id and redirect to /cart/{variant}:{qty},{variant}:{qty}, or use the Storefront API to create a cart and redirect to its checkout URL.
  3. Snipcart or a similar drop-in. Let it own the cart entirely: add its script, put its data attributes on the add buttons, and delete this section from script.js and its styles from style.css.

Whichever you choose, delete the two stub lines above and the honesty note under the button (CART.copy.stub) at the same time, and only then. That note is what stops the page claiming something it cannot do.

Copy and tuning

Everything the cart says, and every number it is tuned by, is in one CART block at the top of the section in assets/script.js:

Key Default What it does
key halvard-selection The localStorage key
version 1 Bump it if you change the stored shape
currency £ The symbol prefixed to every price
maxQty 99 The ceiling on one line's quantity
openTime / closeTime 0.45 / 0.3 Seconds the panel takes to slide
addedFor 1.6 Seconds the add line reads "Added"
copy Every visible string, including the two honest ones

How it behaves

  • Adding does not open the drawer. The count changes, the line says "Added" for a moment and the count plate steps up. A panel that throws itself over the page on every add makes buying three things a fight.
  • Quantity changes patch their own row rather than rebuilding the list, so the button you just pressed stays under the pointer and keeps focus. Removing a line moves focus to the same control on the next line, or to Close when the list has emptied.
  • The empty state is designed, because it is the first state most visitors see: a line of copy and a link back into the collection, with the subtotal and the checkout button removed rather than shown with nothing in them.
  • Two tabs stay in step. A change in one is picked up by the other.
  • A hostile or damaged store is discarded, not trusted. localStorage is a text file the visitor can edit, so every row is re-validated on read and anything that is not a real line is dropped. If storage is unavailable (private browsing, a full quota) the cart still works for that page view; it just will not survive the next navigation.

Accessibility and fallbacks

  • Without JavaScript: no cart control, no add lines, no drawer, and both pages are complete catalogues with every piece, price and link reachable.
  • Reduced motion: the drawer opens and closes and is fully usable, it just does not slide or fade. Handled in both the CSS and the gsap.matchMedia branch.
  • Keyboard: the drawer is a real role="dialog" with aria-modal, labelled by its heading. Focus moves into the panel on open and is trapped while it is open, Escape closes it, and focus returns to the control that opened it. Everything behind the drawer is inert while it is open, and reachable again the moment it closes.
  • Screen readers: one polite live region, outside the drawer so it is never made inert, announces every count change whether the panel is open or shut. Quantity and remove buttons name their piece ("One more Selv drops"), never just "plus" and "minus".
  • Touch: the panel leaves a strip of the page uncovered at every width, so it can always be dismissed by tapping outside it as well as by the Close button. Quantity buttons grow to 44px on a coarse pointer.
  • Smooth scroll: the list carries data-lenis-prevent, without which Lenis swallows the wheel and the drawer will not scroll with a mouse. Keep it if you restyle the panel.

Palette

Four values, and every other colour on the page is one of those four at another opacity. A reskin is an edit to this block at the top of assets/style.css and nothing else:

Code snippet omitted: it ships with the download.

Change each hex and its RGB triple together. Every translucent value in the stylesheet, including the mask stops in the rake light, resolves from one of those triples; a hex changed without its triple leaves rules, dimmed type and shadows mixed from the old colour.

The header comment above the block records the contrast ratio of every pair as it is actually used, including the dimmed variants composited over each surface they land on and the form colours on the malachite field. If you repaint, re-measure those: the two faint variants are what carry most of the reading copy, and they are the pairs that fail first.

There is no palette switcher, by design. The palette is what this template is.

Images

Photographs ship as bundled, optimised WebP derivatives in assets/img/ with the art direction baked into the pixels. Nothing is hotlinked and nothing is fetched at runtime.

File Size Where
hero-ring.webp 1600 x 1600 The hero piece
rake-piece.webp 1400 x 1400 The rake light, used twice
piece-01.webp 900 x 1200 Collection, the large one
piece-02.webp to piece-04.webp 600 x 600 Collection
piece-05.webp 1400 x 700 Collection, the wide one
piece-06.webp to piece-12.webp 600 x 800 The collection page
atelier-bench.webp 1400 x 1750 The atelier

To swap a photograph, drop a replacement into assets/img/ under the same name. Match the listed dimensions and nothing moves. Every photograph has a solid colour behind it from the token block, so the layout holds while images load and does not collapse if one is missing.

To regenerate the whole set from new sources, edit the src paths in assets/img-manifest.json and re-run the asset build:

Code snippet omitted: it ships with the download.

The manifest also carries the grade that makes eight separate photographs read as one campaign: a light desaturation, a small cool hue shift, and a per-image brightness. Set brightness per image, not globally. Sources differ in exposure, and matching perceived brightness rather than nominal brightness is the whole reason a mixed set coheres; expect one photograph to need a very different number from its neighbours.

Note that rake-piece.webp is graded slightly down in the manifest. The section brightens a copy of it in CSS, and a source that is already bright clips to white the moment the light passes over it.

Why the photographs have no edges

Every product shot on a chrome ground is blended into it rather than placed on it. Each image carries mix-blend-mode: multiply and a --lift value: brightness() raises the photograph's seamless studio backdrop to white, and multiplying white by the ground gives the ground exactly, so the rectangle disappears and only the metal, the stone and the piece's own shadow survive as darker values sitting on the field.

--lift is per image and it is 255 divided by that photograph's backdrop brightness. Set too low it leaves a visible dark rectangle; set too high it eats the metal. The values live next to the pieces in assets/style.css for the home page and inline on each <img> on the collection page. Retune them whenever you swap a photograph, and check against a plain screenshot: if you can see the plate's edge, the lift is too low.

This is also why mixed source shapes do not matter on the collection page. The cards hold one 3:4 ratio and the images sit inside them with object-fit: contain; because the letterboxing has the same value as the ground, a landscape piece and a portrait piece look identical in treatment.

Accessibility

  • Contrast. Every pair on the page was measured as used, composited, at the size it is used, and the results are in the :root header comment in assets/style.css. Nothing relies on the 3:1 large-text allowance.
  • Reduced motion. Under prefers-reduced-motion the page is a designed static document, not the animated one with the motion switched off. The rake light freezes at a flattering angle with the piece lit and the shadow at rest, and every reveal target is restored by CSS independently of the script.
  • Keyboard. The rake light takes Arrow Left and Arrow Right. Its focus stop is added by the script and removed on teardown, so it never appears on a page whose JavaScript did not load. Focus outlines are visible on every ground and switch colour on the two dark sections.
  • Touch. A coarse pointer gets the rake light driven by scroll progress through the section rather than nothing, with the smear channel dropped because there is no whip gesture to smear.
  • No JavaScript. Every piece of content is readable with scripting off. The light position and the warmth are ordinary CSS custom properties with sensible initial values, so the piece in the rake section is already lit.
  • Forms. Labels sit above inputs, errors sit below them and are wired with aria-describedby, and no placeholder is used as a label.
  • The cart. A real dialog: focus moves in, is trapped, and returns to the control that opened it; Escape closes it; the rest of the page is inert while it is open. One live region announces every count change. The whole of it is built by the script, so with scripting off there is no cart control anywhere and nothing on either page is unreachable. Full detail in the Cart section above.

Dependencies

Library Version Required
GSAP 3.15.0 Yes
ScrollTrigger 3.15.0 Yes
Lenis 1.3.17 Optional

Smooth scroll is opt-out. Add data-smooth="off" to the <html> tag to ship without it, or append ?smooth=off to the URL to compare a page both ways without editing anything. It is also off automatically under prefers-reduced-motion, which Lenis does not honour by itself.

If you remove Lenis, delete the whole initLenis block at the bottom of assets/script.js rather than part of it. The shared-clock wiring, the opt-out check, the refresh listener and the teardown each fix a different bug, and half of that block reads as correct while still being broken.

Worked examples, the events and programmatic API, and the class reference ship with the download, alongside the full source.