Pinned gallery
A horizontal gallery driven by the vertical scroll.
Documentation
-
Copy to Webflow
Add Geist and Geist Mono to your site's fonts (Site settings, Fonts, Google Fonts) before pasting: Webflow removes any font your site has not installed. -
Add custom CSS
Custom CSS in WebflowThis CSS already ships inside the Webflow embed, so a pasted section needs nothing added. Paste it yourself, in the page or the site custom code, only if the effect runs on several pages. The tutorial explains where.
See the tutorialThe section's height is the engine's: pinned, it is the frame plus the length of the row, so give it none. And no parent of the section may cut its overflow withoverflow: hidden, which stops the frame from pinning; useoverflow-x: clipwhere a parent has to cut. -
Add custom JS
Custom JavaScript in WebflowThis script already ships inside the Webflow embed, settings block included. You paste it yourself, before the closing body tag of the page or the site custom code, only when the effect has to run on several pages, and the tutorial says how to choose.
See the tutorial -
Tune it
How to add a custom attribute in WebflowA custom attribute is a pair, a name and a value, typed in the Settings panel of the element you selected, under Custom attributes. The name is what the script looks for, the value is what it reads. The tutorial shows the panel.
See the tutorialCustom attributesSelect the section on the canvas and open the Settings panel. Under Custom attributes, add a pair: the name on the left, the value on the right.Custom attribute What it does data-pinned-gallery Marks the section that holds the screen while its row runs. It takes no value: leave the value field empty. That section is what the engine makes as tall as the row is long, and what carries the settings. Without it on any section of the page, the embed marks the section holding each row (data-pinned-gallery-track). data-pinned-gallery-frame Marks the block inside the section that holds the screen: pinned, it is sticky, one screen tall, and clips what runs past its edges. Without it the parent of the track is taken, which pins the row alone, with no head or foot around it. [ Required ] data-pinned-gallery-viewport Marks the box that clips the row, the parent of the track. Under 768px it is the row the finger swipes. Without it the parent of the track is taken. [ Required ] data-pinned-gallery-track Marks the row that moves, the block holding the cards. Nothing is mounted without it, and the engine says so in the console. [ Required ] data-pinned-gallery-card Marks one card, a child of the track. Without it on any of them the engine takes the track's own children as the cards. [ Optional ] data-pinned-gallery-image Marks the picture of a card, the one that runs slower than its card. A card without it keeps a still picture, and only shrinks away from the reading line. [ Optional ] data-pinned-gallery-current Marks the number of the card on the reading line. The engine fills it with a rolling strip of every number. Without it there is no current number. [ Optional ] data-pinned-gallery-total Marks the total of the counter, which the engine writes as the card count. Without it there is no total. [ Optional ] data-pinned-gallery-bar Marks the fill of the progress rule, which the engine grows from the left as the row goes. Without it there is no rule to fill. [ Optional ] data-pinned-gallery-state Written by the engine on the section: pinned, native under the native width, reduced under reduced motion. Never written by hand; it is what the stylesheet reads to pin the frame. [ Optional ] data-pinned-gallery-active Written by the engine on the card on the reading line. Never written by hand; the stylesheet reads it to put that card's number in ink. [ Optional ] SettingsEvery setting is a custom attribute too, added the same way and on the same section. An attribute always wins over the defaults written in the code.Attribute Default What it is data-pinned-gallery-speed 1.15 How much page scroll the row takes, in pixels of scroll per pixel the row travels. data-pinned-gallery-scrub 0.8 How long the row takes to catch up with the scroll, in seconds. data-pinned-gallery-drift 0.08 How far each picture travels inside its card, as a share of the card width, from one edge of the screen to the other. data-pinned-gallery-depth 0.06 How much a card one step from the reading line shrinks, as a share of its size. data-pinned-gallery-native 768 Width in pixels under which the row is a plain swipe row instead of a pinned one.
Two files and their pictures (7), no build step.
It runs in the browser only. Load it with a script tag in plain HTML, in a
client-side script in Astro, never in the frontmatter. In React, call
PinnedGallery.mount(ref.current) in useEffect and
destroy() in its cleanup.
-
Tune it, either way
Per element with an attribute, or once for the whole site by editingDEFAULTSat the top of the file.Attribute Default What it is Lower Higher data-pinned-gallery-speed 1.15 How much page scroll the row takes, in pixels of scroll per pixel the row travels. The row runs fast and the section lets go sooner The row runs slowly, and the page stays pinned for longer data-pinned-gallery-scrub 0.8 How long the row takes to catch up with the scroll, in seconds. 0 locks the row to the scroll, frame for frame The row lags behind and glides to a stop data-pinned-gallery-drift 0.08 How far each picture travels inside its card, as a share of the card width, from one edge of the screen to the other. The pictures move with their cards, flat A deep parallax; the pictures are enlarged by as much, so they are cropped more data-pinned-gallery-depth 0.06 How much a card one step from the reading line shrinks, as a share of its size. Every card stays full size The cards away from the line fall well back data-pinned-gallery-native 768 Width in pixels under which the row is a plain swipe row instead of a pinned one. The pin is kept on narrower screens, down to phones The swipe row takes over on tablets too -
Drive it from JavaScript
When the cards change after load, when a button has to bring a card into view, or when the page reacts to the card on the reading line.const element = document.querySelector('[data-pinned-gallery]'); element.addEventListener('pinnedgallery:change', (e) => { console.log(e.detail); }); const gallery = PinnedGallery.mount(element); // the section auto() mounted, or a new one gallery.go(4); // the fifth card onto the reading line gallery.refresh(); // after the cards changed element.addEventListener('pinnedgallery:change', (e) => { console.log(e.detail.index); // the card on the reading line }); // When the gallery leaves the page: PinnedGallery.destroy(element); // gives back the markup as it was
-
Fit it to your page
- Mark the parts.
data-pinned-galleryon the section, then inside it-frameon the block that holds the screen,-viewporton the box that clips the row,-trackon the row itself and-cardon each card, a child of the track. Optional:-imageon the picture of a card (the one that drifts),-currentand-totalon the two numbers of the counter,-baron the fill of the progress rule. A track with no card marked takes its own children as the cards. - The frame is the screen. Pinned, the stylesheet makes it
position: sticky; top: 0; height: 100vh. Lay out its head, row and foot so they fit one screen: the preview's frame is a grid of three rows, the row taking what the head and foot leave, and its cards are sized on the screen height as much as its width. - One setting for every gallery of the page, without opening the file:
window.PinnedGallerySettings = { speed: 1.4 }in a script placed beforepinned-gallery.js. Three levels, least specific first:DEFAULTS, then that object, then adata-pinned-gallery-*attribute on the section. - Three states, written by the engine as
data-pinned-gallery-stateon the section:pinned(the section holds and the row is driven),native(undernativepx: a row the finger swipes, snapping card by card, the counter and rule read on its scroll),reduced(underprefers-reduced-motion: reduce: a plain row that scrolls sideways, nothing pinned, no snap, no counter transition). The state is chosen again on resize and when the motion setting changes. - The card on the reading line carries
data-pinned-gallery-active. The reading line runs from the centre of the first card, at the start, to the centre of the last one, at the end, so each card has its turn even where the row cannot bring it to the middle of the screen: the counter reads 01 at the start and 08 at the end. - Keyboard: the cards are links. Pinned, a card that takes the focus from the keyboard (
:focus-visible, not a click) brings the page to where that card is on the reading line, so tabbing walks the row. - From JavaScript:
PinnedGallery.mount(el, overrides)returns the instance:element,settings,cards,state(),index()(the card on the line),progress()(0 to 1),go(i)(brings card i onto the line, scrolling the page when pinned),refresh()(measures again) anddestroy().destroy()takes off every listener, the watch on sizes, the counter it built, the state and every style it wrote, so the markup is as it was.PinnedGallery.destroy(el),PinnedGallery.refresh(el),PinnedGallery.auto(root)andPinnedGallery.defaultscomplete it. In React,mountinuseEffectanddestroyin its cleanup. - Event:
pinnedgallery:change, bubbling, each time another card takes the reading line,detail{ element, index, card, cards }. It fires once at mount, for the first card. - The preview ships whole.
snippet.html, the pictures ofimages/and the last part ofpinned-gallery.css, after the linelook, left out of the Webflow Embed, are the preview: the same intro, cards, words, pictures and order desk, in Geist and Geist Mono, loaded by the font tag of the Code tab. Thepinned-gallery-*classes are never read by the engine: on a gallery of your own, keep your classes, add the attributes, and drop that last part of the file and the font tag. The states of the look (the number in ink on the active card, the keyboard ring, the header links) sit just before that line and find their elements by the attributes the preview's markup carries for them,data-pinned-gallery-number,-media,-plateand-link, never by a class: Webflow renames a class pasted twice on one site (AB-116). Drop them too. - On Webflow, add the fonts first. Add Geist and Geist Mono to your site's fonts (Site settings, Fonts, Google Fonts) before pasting: the paste names them, and the Designer drops a font the site does not have.
- The words of the markup are the preview's, and they ship as they are: pasted, the page reads the way the preview did. Put the user's own content in their place whenever they want, in the page's language.
- Mark the parts.
-
Avoid the pitfalls
- An ancestor that cuts its overflow kills the pin.
overflow: hiddenon any parent of the section makes it the scroll container of the sticky frame, and a container that does not scroll has nowhere to pin: the frame simply scrolls away with the page. Useoverflow-x: clipwhere a parent has to cut, which cuts without making a scroll container. - The stylesheet is what pins. Without
pinned-gallery.cssnothing is sticky and the section keeps its own height; the engine still moves the row, over a section that scrolls away. - Do not give the section a height. Pinned, its height is the frame plus the length of the row, written by the engine as
--pinned-gallery-heightand read again on every resize, font and picture that changes the row. A fixed height, or amin-heighttaller than that, leaves the section pinned on an empty stretch or letting go early. - **Settings handed to
mount()after the page loaded are dropped.** The script mounts every[data-pinned-gallery]on DOM ready, andmount()on a mounted section returns the running instance. A page-wide block goes inwindow.PinnedGallerySettings, whichauto()reads itself; a section that changed its layout is measured again byrefresh(). - The edge of a picture is cut, and not the same edge all the time. The picture is drawn at
1 + driftand moves by up todrift / 2of the card width either way, so with the default 0.08 up to 7.4 % of the width is cut on one side and 4 % of the height at the top and the bottom. A picture with a printed frame shows that frame as a line sliding along the card's edge, and a word near the edge is cut. Keep the marks of a picture about 12 % in from every side, as the preview's pictures do, or lowerdrift. - No ScrollTrigger, on purpose. Webflow's
Webflow.destroy()kills every ScrollTrigger of a page; this engine has none, reads the scroll itself and has nothing to rebuild.
- An ancestor that cuts its overflow kills the pin.
One click on Copy the AI prompt copies everything an assistant needs to build this effect: the complete code, the markup, the dependency, every setting and every pitfall.
What it does
Pinned gallery is a scroll animation for Webflow and vanilla JavaScript, with no library. Also called horizontal scroll gallery, horizontal scroll or sticky horizontal scroll.
A horizontal gallery driven by the vertical scroll. The section holds the screen while its row of cards runs sideways, a counter reads the card in front ("03 / 08") and a rule fills as the row goes; past the last card the section lets go and the page scrolls on. Each picture runs a little slower than its card and is enlarged by as much, so it never shows an edge, and the cards away from the reading line are a little smaller, which gives the row its depth. No library: the hold is a sticky frame inside a section the engine makes exactly as tall as the row is long, and the row is one transform, so there is no spacer, no scroll trigger, and nothing for Webflow.destroy() to take away. Under 768px the row becomes a plain swipe row that snaps card by card, and under reduced motion a plain sideways row. Pattern type horizontal scroll, pinned gallery, scroll jacking without the jacking.
Updated
Questions
Mark the section with the custom attribute data-pinned-gallery, then inside it add data-pinned-gallery-frame, data-pinned-gallery-viewport, data-pinned-gallery-track and data-pinned-gallery-card on each card. Do not give the section a height: the engine makes it as tall as the frame plus the length of the row.
Yes. This horizontal scroll gallery uses no library and no ScrollTrigger: the hold is a position: sticky frame, and the row is one transform per frame. Webflow.destroy() has nothing to take away.
Under 768 pixels wide, the default of the native setting, nothing is pinned: the row becomes a plain swipe row that snaps card by card. Raise data-pinned-gallery-native and the swipe row takes over on tablets too. Under prefers-reduced-motion: reduce it is a plain row that scrolls sideways, with no pin and no snap.
Raise data-pinned-gallery-speed, the pixels of page scroll per pixel the row travels, 1.15 by default: higher and the row runs slowly while the page stays pinned for longer. data-pinned-gallery-scrub, 0.8 seconds by default, is how long the row takes to catch up with the scroll, and 0 locks it to the scroll frame for frame.
A parent of the section set to overflow: hidden becomes the scroll container of the sticky frame, so the frame scrolls away with the page. Switch that parent to overflow-x: clip. Check the stylesheet too: without pinned-gallery.css nothing is sticky.