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 use SwCarousel. 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:

  1. 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).
  2. #controls slot (custom controls): when you want fully custom arrow/control styling, use the #controls scoped slot to get scrollPrev / scrollNext / canPrev / canNext and render them yourself. Passing #controls overrides the default arrows (the built-in arrows are no longer rendered).
  3. defineExpose (buttons anywhere): to place paging buttons anywhere on the page outside the rail (e.g. next to a section heading), grab scrollPrev / scrollNext / canPrev / canNext via 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

  • snap controls the CSS scroll-snap-type strength: 'proximity' (default, gentle snap) / 'mandatory' (force-snap each item) / 'none' (free scroll, no snap).
  • scrollBy controls 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 / End jump to start / end.
  • When ariaLabel is provided, it outputs role="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

NameTypeRequiredDefaultDescription
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)
arrowsbooleantrueWhether to render the default left/right arrows. A #controls slot takes precedence (default arrows not rendered); false disables the default arrows entirely
ariaLabelstringundefinedOnly when set, outputs role="region" + aria-label

Card spacing is controlled by the CSS variable --sw-rail-gap (default 0.75rem — a non-sticking baseline; override with any value including 0), not a prop.

Events

SwRail currently emits no events.

Slots

NameScopeDescription
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)

MemberSignatureDescription
scrollPrev(opts?: { immediate?: boolean }) => voidScroll left one step (immediate = no animation)
scrollNext(opts?: { immediate?: boolean }) => voidScroll right one step
refresh() => voidRecompute canPrev / canNext after content/size changes
canPrevReadonly<Ref<boolean>>Whether it can still scroll left (false at the leftmost)
canNextReadonly<Ref<boolean>>Whether it can still scroll right (false at the rightmost)
scrollToStart() => voidSmooth-scrolls back to the start — the official way to reset after refreshing / swapping the list
scrollTo(left: number) => voidSmooth-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)

加载中...
VariableDefaultDescription
--sw-rail-gap0.75remCard spacing (non-sticking baseline, override allowed incl. 0)
--sw-rail-arrow-colorrgb(255 255 255)Arrow icon color (white by default)
--sw-rail-arrow-bgrgb(0 0 0 / 40%)Arrow background on container hover (semi-transparent black by default, not the brand color)
--sw-rail-arrow-size2.25remArrow button size
--sw-rail-arrow-offset0.5remHorizontal distance of the arrows from each edge
--sw-rail-arrow-top50%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-radius999pxArrow corner radius (circular by default)
--sw-rail-arrow-opacity0.55Idle-state opacity (semi-transparent, no background; the disabled state uses this same value to stay left/right symmetric)
--sw-rail-arrow-hover-opacity1Opacity 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-bg background 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 auto on direct children, so give cards an explicit width (otherwise nothing forces overflow and there's nothing to scroll).
  • Use SwCarousel for auto-rotation: SwRail intentionally omits loop / 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 #controls slot / defineExpose only when you need custom controls or want the buttons elsewhere.