@page-view/svelte
v0.1.5
Published
Headless, Flutter-PageView-inspired paginated scroll/carousel primitive for Svelte 5 (runes) — gestures, wheel, keyboard, autoplay, RTL and lazy-loading built in.
Maintainers
Readme
@page-view/svelte
A headless, Flutter PageView-inspired
paginated scroll/carousel primitive for Svelte 5 (runes). Gestures, wheel,
keyboard, autoplay, RTL and lazy-loading are all built in — styling is entirely
up to you.
- Zero runtime dependencies — nothing beyond Svelte itself.
- Headless — no imposed CSS beyond scroll-snap mechanics; bring your own classes.
- SSR-safe — no top-level
window/documentaccess, no hydration-mismatch-prone IDs. - Accessible by default — keyboard navigation works out of the box; indicators render as real
<button>s.
Install
npm install @page-view/svelteRequires svelte@^5 as a peer dependency.
Quick start
<script lang="ts">
import { PageView, PageItem } from '@page-view/svelte';
const slides = ['One', 'Two', 'Three'];
</script>
<PageView>
{#snippet children(ctrl)}
{#each slides as slide, i (slide)}
<PageItem controller={ctrl} index={i}>
<div class="flex h-64 items-center justify-center bg-base-200">{slide}</div>
</PageItem>
{/each}
{/snippet}
</PageView>That's it — swipe, arrow keys, and (on wide/pointer-first viewports) wheel navigation all work with no further setup.
Autoplay
<AutoPlayView> wraps <PageView> and adds a pause-on-hover timer:
<script lang="ts">
import { AutoPlayView, PageItem } from '@page-view/svelte';
</script>
<AutoPlayView interval={4000} autoplayEnabled={true}>
{#snippet children(ctrl, autoCtrl)}
<!-- same as PageView; autoCtrl exposes start()/stop()/setHovered() if needed -->
{/snippet}
</AutoPlayView>Hover-pause is automatic — <AutoPlayView> pauses the timer while the pointer is over it and resumes on leave, no prop needed.
Viewport & RTL — supplied by your app, live-reactive
'auto' input modes (see below) resolve against a breakpoint (matching a
Tailwind-style xs | sm | md | lg | xl | 2xl | 3xl scale) and RTL state.
This package does not read window/document to guess either of these —
that's real environment detection, which belongs in your app, not a headless
primitive. Omit them and you get a neutral static fallback ('lg', false);
supply them for correct 'auto'-mode behavior.
Every PageController option — not just these two — is read live, not
snapshotted at construction: the constructor stores the options object
itself, so a property defined with JS getter syntax stays reactive with no
$effect needed:
<PageView
viewport={myBreakpoint}
rtl={myPrefs.isRtl}
/><PageView>/<AutoPlayView> already forward every prop this way internally
— that's how they stay in sync with your reactive $props() without a
remount. If you're constructing a PageController directly outside a
component, use a getter to keep it live:
const ctrl = new PageController({
get rtl() {
return myPrefs.isRtl;
}
});A plain value ({ rtl: true }) works identically for static/one-off use —
the controller can't tell a getter from a plain property.
For RTL layout (button placement, logical CSS properties), wrap in
dir="rtl" — independent of the rtl option above, which only drives
scroll math and gesture/wheel direction:
<div dir="rtl">
<PageView rtl={true}>...</PageView>
</div>Input control modes
gesturesState, wheelState, and keyboardState each accept
'enabled' | 'disabled' | 'auto':
| Input | 'auto' resolution | Default |
| --------- | ----------------------------------------------------------------------- | ---------- |
| gestures | touch-first breakpoints (xs/sm/md) → on, others → off | 'auto' |
| wheel | pointer-first breakpoints (lg/xl/2xl/3xl) → on, others → off | 'disabled' on horizontal, 'auto' on vertical |
| keyboard | always on | 'auto' |
Listeners are only ever attached when the final resolved value is true —
'disabled' costs nothing at runtime (no listener registered at all).
Unbounded/bidirectional content (opt-in)
For content that grows at the edges over time (an endless timeline, infinite
history, etc.) — not the same thing as loop, which wraps a fixed,
already-known page count. Two small, independent, opt-in primitives, both
undefined/no-op by default:
new PageController({
onReachStart: () => {
/* prepend more pages, then: */
controller.notifyPagesPrepended(count);
},
onReachEnd: () => {
/* just append more pages — nothing else needed */
},
edgeThreshold: 1 // how many pages from an edge counts as "near" it
});onReachStart/onReachEndfire once on entering the edge zone (not on every settle while already inside it), so you don't need your own re-fire guard.notifyPagesPrepended(count)— call after insertingcountpages before the existing ones. Prepending shifts every existing page's position; this correctscurrentPageand the scroll offset instantly (no measurement needed — every page is the same known size) so the view doesn't visibly jump. Appending needs no equivalent call — nothing before the current page moved.
This package has no opinion on what a page contains or how more of them get
created — see docs/infinite-scroll-research.md in the repo for the design
rationale (compared against react-virtuoso, TanStack Virtual, Flutter's
sliver anchoring, and IntersectionObserver-based infinite scroll before
settling on this shape).
Public API
import {
// Controllers (framework-agnostic state, usable outside a component too)
PageController,
AutoplayController,
// Components
PageView,
AutoPlayView,
PageTrack,
PageItem,
PageIndicator,
PageNavButton,
// Enums / unions
ScrollDirection, // 'horizontal' | 'vertical'
ScrollPhysics, // 'bouncing' | 'clamped' | 'never'
IndicatorPosition,
InputControlMode, // 'enabled' | 'disabled' | 'auto'
Breakpoint, // 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl'
// Lower-level building blocks (reuse if you're composing something custom)
getPageSize,
getPageOffset,
computeCurrentPage,
scrollToPage,
canGoToNextPage,
canGoToPrevPage,
physicsToOverscrollBehavior,
getSnapType,
clamp,
// Constants
DEFAULT_BREAKPOINT,
DEFAULT_BREAKPOINT_MIN_WIDTH_PX,
DEFAULT_EDGE_THRESHOLD,
TOUCH_FIRST_BREAKPOINTS
// ...plus every corresponding *Options / *Props type.
} from '@page-view/svelte';PageController is the framework-agnostic navigation core (works even
outside a .svelte file); AutoplayController composes a PageController
instance and adds a pause-on-hover timer. PageNavButton's default chevron
icon is a plain inline SVG — pass the icon snippet prop to override it with
anything (an icon library, an emoji, a custom asset).
Accessibility, dark mode, RTL
- Keyboard navigation (arrow keys) is on by default and works without any configuration.
<PageIndicator>renders real<button>elements (role="tab",aria-selected,aria-label).- No imposed color/theme — style via your own classes (works fine with DaisyUI/Tailwind dark-mode variants, since none of this package's CSS hardcodes a palette).
- RTL: see Viewport & RTL above.
