HorizontalScroll
A full-viewport horizontally scrolling shell whose children flow as panels. The mouse wheel scrolls it sideways by default, and hands control back to the page at either end. Pair it with Parallax bands set to axis="x" for layers that drift sideways as the shell scrolls.
Import
import { HorizontalScroll } from "@hyzer-labs/ui"Demo
Every demo below sets --hz-horizontal-scroll-height to a small height with a page
class, so it scrolls in place instead of taking over the viewport the way a real full-page shell
would. There's no separate demo box here: what you're looking at is the component, at any height you give it.
The scrollbar stays on purpose. At rest it is the only sign that the shell scrolls sideways, and
it gives a pointer path to anyone who cannot drag or use a wheel. There's no prop to hide it,
but your own scrollbar-width: thin will slim it. If a shell like this is your
page's main content, give it role="region" and a label, or use as="main": a plain scroll region only needs to be a tab stop for keyboard access,
but a landmark needs both a role and a name. A panel that needs to scroll vertically should be
its own scroller, since this shell only ever moves sideways.
A panel is any direct child. There's no Panel subcomponent and no wrapper
element. wheel is on by default, so a plain mouse wheel already moves the shell
sideways with nothing switched on. Try it below, along with the scrollbar, a two-finger trackpad
pan, a touch swipe, or tabbing in and pressing an arrow key. Then keep wheeling once you reach
the last panel: the shell hands the wheel back, and this page keeps scrolling underneath you.
Panel 1
Panel 2
Panel 3
Panel 4
<HorizontalScroll>
<section>Panel one</section>
<section>Panel two</section>
<section>Panel three</section>
<section>Panel four</section>
</HorizontalScroll>
<!-- wheel is on by default: a plain mouse wheel already moves this
sideways, and it hands off to the page at either end -->One custom property controls how much of the shell each panel fills. 100% gives one panel per screen. A smaller value like 70% leaves the next panel
peeking in, a hint that there's more. auto sizes each panel to its own
content instead of to the shell. --hz-horizontal-scroll-gap adds space between
panels, and you can set both per breakpoint in your own class.
Panel 1
Panel 2
Panel 3
Panel 4
<HorizontalScroll
style="--hz-horizontal-scroll-panel-width: 100%;"
>
…panels…
</HorizontalScroll>Free-flowing scrolling is the default here, on purpose. Unlike the carousel rail's small
cards, these panels are viewport-sized and meant to be read while they move, so
snapping every panel into place would fight each partial gesture. Turn snap on
when you want panels to land cleanly instead. Compare the two below.
Free-flowing (default)
Panel 1
Panel 2
Panel 3
Panel 4
snap
Panel 1
Panel 2
Panel 3
Panel 4
<HorizontalScroll> <!-- free-flowing, the default -->
…panels…
</HorizontalScroll>
<HorizontalScroll snap> <!-- snaps to each panel start -->
…panels…
</HorizontalScroll>Want exactly one panel per gesture, even on a fast flick? The component does not set
that, so add scroll-snap-stop: always to your own panel class. Without it, a
fast swipe can sail straight past a snap point.
Set wheel to false and the mouse wheel scrolls the page again, same
as any other block. The shell then answers only to touch, trackpad panning, the scrollbar,
and the keyboard. Pinch-zoom, shift+wheel, and a trackpad's own horizontal pan are left alone
either way, wheel on or off. If a panel scrolls vertically on its own (a long list, an overflowing
card), an enabled remap defers to it: wheeling over that inner scroller scrolls it first,
and the shell takes over only once the inner one reaches its end in that direction.
Panel 1
Panel 2
Panel 3
Panel 4
<HorizontalScroll wheel={false}>
…panels…
</HorizontalScroll>A Parallax band works as a panel: it gets its height from flex stretch, so
it needs no min-height, unlike a band in normal page flow. Each one also
needs axis="x", because the shell never scrolls vertically and a band left on the
default axis="y" would sit still. In panel one the circles start far apart,
near the top and bottom, and opposing y (cross-axis) travel brings them
together as you scroll. In panel two the shapes start staggered, and giving each layer a
different x travel drifts them at different speeds. The motion rules are the
same here: layers stop drifting for visitors who ask for less motion, while scrolling the
shell itself always works.
Panel one
Panel two
Panel three
<HorizontalScroll>
<!-- panel one: two circles start far apart, near the top and bottom,
and opposing y (cross-axis) travel brings them together as you
scroll right -->
<Parallax axis="x" class="panel">
<ParallaxLayer y="16rem">…top, drifts down…</ParallaxLayer>
<ParallaxLayer y="-16rem">…bottom, drifts up…</ParallaxLayer>
<Container><h3>Panel one</h3></Container>
</Parallax>
<!-- panel two: classic speed-difference drift — staggered layers,
same-axis x travel at different magnitudes -->
<Parallax axis="x" class="panel">
<ParallaxLayer x="4rem">…</ParallaxLayer>
<ParallaxLayer x="9rem">…</ParallaxLayer>
<ParallaxLayer x="16rem">…</ParallaxLayer>
</Parallax>
<Parallax axis="x" class="panel">…</Parallax>
</HorizontalScroll>Props
| Name | Type | Default | Note |
|---|---|---|---|
as | string | 'div' | Rendered via <svelte:element>. 'section' and 'main' are common choices. |
snap | boolean | false | CSS scroll-snap at panel starts. Off by default, because panels are viewport-sized and meant to be read while they move, unlike a rail of small cards. |
wheel | boolean | true | Turns a plain, vertical-dominant wheel notch into horizontal travel. On by default, since it is most of what makes the shell feel right under a mouse. It defers to a nested vertical scroller, and hands the wheel back to the page at either end. Set wheel={false} for native-only scrolling (touch, trackpad, scrollbar, keyboard). |
children | Snippet | — | The panels, as direct children. There is no Panel subcomponent: any element you write directly inside is a panel. |
class | string | — | Merged after the hz-horizontal-scroll class. |
Anything not listed above is forwarded as an attribute to the root element (or the native control in form components). So id, data-*, aria-*, and event handlers just work.
Theme hooks
What this component promises your CSS. The reference theme styles exactly these — from @layer hz-theme, so your unlayered rules win. See Styling Components for the how.
Root class: .hz-horizontal-scroll
Data attributes
| Hook | Values | Styles |
|---|---|---|
data-snap | present when snapping is on | Drives scroll-snap-type: x mandatory on the root. Absent by default — free continuous scrolling. |
data-wheel | present once the wheel remap is actually listening (client-side only) | Reflects the attached listener, not the wheel prop — absent server-side and pre-hydration, and absent whenever wheel is false. |
data-wheeling | present while a remapped wheel burst is in flight | Suppresses scroll-snap-type for the duration so a mandatory snap does not fight a live scrollLeft assignment; clears ~150ms after the last consumed event, and the browser settles to the nearest panel on its own. Never present when wheel is false. |
Custom properties
| Hook | Values | Styles |
|---|---|---|
--hz-horizontal-scroll-height | <length> — default 100dvh | The shell block-size. dvh survives a mobile URL bar collapsing; override for an embedded (non-full-viewport) shell. |
--hz-horizontal-scroll-panel-width | <length | percentage> — default 100% | One knob for how much of the shell a panel fills. auto sizes each panel to its content. |
--hz-horizontal-scroll-gap | <length> — default 0 | Gap between panels. |
Accessibility
The shell is a keyboard tab stop with native arrow-key scrolling. Home and End jump to the first or last panel, instantly when the visitor asks for less motion. Every panel is in the normal tab order, in DOM order, and tabbing to an off-screen panel scrolls it into view on its own. The component adds no role and no accessible name, because a focusable scroll region needs neither for keyboard access. If this shell is your page's main content, give it role="region" aria-label="…" yourself, or use as="main".
The wheel remap is on by default. It only ever takes plain, unmodified, vertical-dominant wheel input that the shell can use. It never takes pinch-zoom, shift+wheel, a trackpad pan, touch, the scrollbar, or the keyboard, and it stops at either end, where scrolling goes straight back to the page. Set wheel={false} for native-only scrolling. No setting leaves a visitor unable to scroll away.