SwRail
SwRail is a horizontal free-scrolling content rail for the "a row of N cards that overflows the viewport" pattern (cover recommendations for movies/comics/novels, horizontal link strips, etc.).
It is built on native scroll + CSS scroll-snap: finger swiping and momentum on mobile/tablet are provided natively by the browser (zero extra config); on desktop it ships a pair of built-in left/right arrows (surface on container hover, auto-disable at the edges) with no extra config. Under SSR it progressively enhances as a native scrollable container (scrolls even without JS).
No carousel behavior: SwRail has no
loop/autoplay— for an auto-rotating banner useSwCarousel. SwRail only does "free horizontal scrolling".
Basic usage
Just put cards in the default slot — the desktop arrows render automatically (hover devices only, hidden on mobile, auto-disabled at the edges); no control code needed. Card spacing is controlled by the CSS variable --sw-rail-gap.
Horizontal scroll + default arrows
<SwRail aria-label="Recommended" style="--sw-rail-gap: 0.75rem">
<Card v-for="m in list" :key="m.id" :item="m" />
</SwRail>
To hide the default arrows, pass :arrows="false":
<SwRail aria-label="Recommended" :arrows="false">
<Card v-for="m in list" :key="m.id" :item="m" />
</SwRail>
Three ways to control it
SwRail offers three ways to page through content; pick by complexity:
- Default arrows (least effort): pass nothing — a pair of built-in left/right arrows is rendered. Shown on desktop only (
@media hover), hidden on mobile (swipe instead), auto-disabled at the edges. Its four states (idle semi-transparent / container-hover background / button-hover full white / disabled symmetric to idle) are all driven by CSS variables (see "CSS variables" below). #controlsslot (custom controls): when you want fully custom arrow/control styling, use the#controlsscoped slot to getscrollPrev/scrollNext/canPrev/canNextand render them yourself. Passing#controlsoverrides the default arrows (the built-in arrows are no longer rendered).defineExpose(buttons anywhere): to place paging buttons anywhere on the page outside the rail (e.g. next to a section heading), grabscrollPrev/scrollNext/canPrev/canNextvia a template ref and bind them yourself (see "Imperative control").
#controls slot (custom controls)
Custom controls via the #controls slot
<SwRail aria-label="Recommended" style="--sw-rail-gap: 0.75rem">
<Card v-for="m in list" :key="m.id" :item="m" />
<template #controls="{ scrollPrev, scrollNext, canPrev, canNext }">
<button :disabled="!canPrev" @click="scrollPrev()">‹</button>
<button :disabled="!canNext" @click="scrollNext()">›</button>
</template>
</SwRail>
Snap & scroll distance
snapcontrols the CSSscroll-snap-typestrength:'proximity'(default, gentle snap) /'mandatory'(force-snap each item) /'none'(free scroll, no snap).scrollBycontrols how far each arrow click scrolls:'page'(default, ~90% of the container width) or an exact pixel number. On mobile, how many items the momentum carries is decided by native scrolling and is unaffected by this prop.
Comparing the three snap strengths
<!-- Force-snap + 600px per arrow click -->
<SwRail snap="mandatory" :scroll-by="600">
<Card v-for="..." />
</SwRail>
Imperative control
Call the exposed methods via a template ref:
<script setup>
const railRef = ref()
</script>
<template>
<SwRail ref="railRef"><Card v-for="..." /></SwRail>
<button @click="railRef.scrollNext()">Next</button>
</template>
Keyboard & accessibility
- The container is focusable:
←/→scroll one step,Home/Endjump to start / end. - When
ariaLabelis provided, it outputsrole="region"+aria-label(omitted otherwise, to avoid an unnamed region). - When the system "reduce motion" preference (
prefers-reduced-motion) is active, scrolling switches to instant (no animation).
Props
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
snap | 'none' | 'proximity' | 'mandatory' | 'proximity' | Maps to CSS scroll-snap-type strength | |
scrollBy | 'page' | number | 'page' | Distance per arrow click ('page'≈0.9× container width / number=pixels) | |
arrows | boolean | true | Whether to render the default left/right arrows. A #controls slot takes precedence (default arrows not rendered); false disables the default arrows entirely | |
ariaLabel | string | undefined | Only when set, outputs role="region" + aria-label |
Card spacing is controlled by the CSS variable
--sw-rail-gap(default0.75rem— a non-sticking baseline; override with any value including0), not a prop.
Events
SwRail currently emits no events.
Slots
| Name | Scope | Description |
|---|---|---|
default | — | Card content (via v-for); children are auto-set to flex: 0 0 auto (no shrink) |
controls | { scrollPrev, scrollNext, canPrev, canNext } | Custom controls (overrides the default arrows), styled by the caller |
Exposed methods (defineExpose)
| Member | Signature | Description |
|---|---|---|
scrollPrev | (opts?: { immediate?: boolean }) => void | Scroll left one step (immediate = no animation) |
scrollNext | (opts?: { immediate?: boolean }) => void | Scroll right one step |
refresh | () => void | Recompute canPrev / canNext after content/size changes |
canPrev | Readonly<Ref<boolean>> | Whether it can still scroll left (false at the leftmost) |
canNext | Readonly<Ref<boolean>> | Whether it can still scroll right (false at the rightmost) |
scrollToStart | () => void | Smooth-scrolls back to the start — the official way to reset after refreshing / swapping the list |
scrollTo | (left: number) => void | Smooth-scrolls to a given horizontal offset |
CSS variables
The default arrows are fully styled via CSS variables — set them on the SwRail element or any ancestor to override. Card spacing uses a variable too.
Customizing arrows with CSS variables (bigger / always visible / dark background)
refresh() after async load (SR-002)
| Variable | Default | Description |
|---|---|---|
--sw-rail-gap | 0.75rem | Card spacing (non-sticking baseline, override allowed incl. 0) |
--sw-rail-arrow-color | rgb(255 255 255) | Arrow icon color (white by default) |
--sw-rail-arrow-bg | rgb(0 0 0 / 40%) | Arrow background on container hover (semi-transparent black by default, not the brand color) |
--sw-rail-arrow-size | 2.25rem | Arrow button size |
--sw-rail-arrow-offset | 0.5rem | Horizontal distance of the arrows from each edge |
--sw-rail-arrow-top | 50% | Vertical anchor of the arrows (translateY(-50%) centers on this line). When cards are "cover + text below", set calc((100% - <text-block-height>) / 2) to center the arrows on the cover image |
--sw-rail-arrow-radius | 999px | Arrow corner radius (circular by default) |
--sw-rail-arrow-opacity | 0.55 | Idle-state opacity (semi-transparent, no background; the disabled state uses this same value to stay left/right symmetric) |
--sw-rail-arrow-hover-opacity | 1 | Opacity when hovering the button itself (full white) |
<!-- Darker arrow background, larger size -->
<SwRail
aria-label="Recommended"
style="
--sw-rail-gap: 1rem;
--sw-rail-arrow-bg: rgb(0 0 0 / 60%);
--sw-rail-arrow-size: 3rem;
"
>
<Card v-for="m in list" :key="m.id" :item="m" />
</SwRail>
The default arrows only show on hover devices (desktop) and are hidden on mobile (swipe instead). Four states: ① idle (semi-transparent, no background) ② container hover (the
--sw-rail-arrow-bgbackground appears) ③ button hover (arrow goes full white) ④ disabled (at the edges — looks like ①, not clickable).
Notes
- Children don't shrink: SwRail sets
flex: 0 0 autoon direct children, so give cards an explicit width (otherwise nothing forces overflow and there's nothing to scroll). - Use
SwCarouselfor auto-rotation: SwRail intentionally omitsloop/autoplay. - Three platforms: touch scrolling on mobile/tablet is native browser behavior (no extra config); desktop ships default arrows (shown on hover) — reach for the
#controlsslot /defineExposeonly when you need custom controls or want the buttons elsewhere.