Skip to main content

rc-page-turner

Shows one page at a time from a stack of pages and turns between them like the pages of a book, following the WAI-ARIA APG Carousel pattern without automatic rotation. rc-page-turner is the stack and its optional previous/next buttons; rc-page-turner-page wraps each page's authored light-DOM content.

Package
@rcarls/rc-page-turner
Element
<rc-page-turner>
Native dependency
rc-page-turner-page children are slotted light DOM
State model
Controlled or uncontrolled active-index
Main events
rc-page-turner-change

Installation​

npm install @rcarls/rc-webcomponents@next
import '@rcarls/rc-webcomponents/rc-page-turner/define';

Usage​

<rc-page-turner navigation aria-label="Recipe steps">
<rc-page-turner-page><h2>Heat the oven to 350°F.</h2></rc-page-turner-page>
<rc-page-turner-page><h2>Cream the butter and sugar.</h2></rc-page-turner-page>
<rc-page-turner-page><h2>Fold in the flour.</h2></rc-page-turner-page>
</rc-page-turner>

Give the host a definite block-size, or place it in a layout that provides one. Pages share one grid cell, and a page that isn't shown takes no space, so an unsized turner follows the current page's height. Pages are opaque by default (--rc-page-turner-page-background: Canvas): a turning page has to hide the page beneath it.

Live demo​

Current page​

active-index/default-active-index follow this monorepo's controlled/ uncontrolled property convention. Host writes are silent. The buttons, keys, next(), previous(), goToIndex(index, instant?), and drags report through rc-page-turner-change (detail: { index, trigger }, trigger one of 'api' | 'button' | 'keyboard' | 'swipe'). In controlled usage the turner doesn't move until the host writes the new index:

<rc-page-turner id="steps" active-index="0" aria-label="Recipe steps">…</rc-page-turner>

<script>
const steps = document.querySelector('#steps');

steps.addEventListener('rc-page-turner-change', (event) => {
steps.activeIndex = event.detail.index;
});
</script>

There is no loop: the first and last pages are boundaries, and the previous and next buttons show aria-disabled="true" there.

Turning and performance​

Every page stays connected for the turner's whole life, so turning never rebuilds page content: form state, focusable controls, and id references survive. A page that isn't shown skips its own style, layout, and paint through content-visibility: hidden, and is inert and aria-hidden.

A turn animates only the one moving page's transform, through the Web Animations API, so it runs on the compositor:

  • Forward, the current page swings away around the spine and uncovers the next page.
  • Back, the previous page swings in over the current one.
  • The spine is the inline-start edge: the left edge in left-to-right text, the right edge in right-to-left text, and the top in vertical text.

--rc-page-turner-duration and --rc-page-turner-easing set the turn's timing, and --rc-page-turner-perspective its depth. The turner reads them when a turn starts. A back turn uses the easing as written; a forward turn plays it in reverse, so with a decelerating curve the departing page lifts slowly and stays in view as long as an arriving page takes to settle. Under prefers-reduced-motion: reduce or forced colors the turn becomes a fade of at most 150ms. goToIndex(index, true) and engines without Element.animate() swap pages with no motion. A turn requested during a turn lands the first one immediately, so two turns never overlap.

Past 90° a turning page shows a blank back as it falls beyond the inline-start edge: a layer the turner draws in step with the page, mirrored across its center, so it faces the viewer exactly when the page's front turns away. Color it with --rc-page-turner-page-back-background or style the page-back part. The viewport clips across the inline axis only, so the back can draw past the turner's inline-start edge; leave room there, or clip an ancestor, if nothing should show beyond it.

Pages expose custom states for styling a turn, such as a shadow on the moving page: :state(active) for the current page, :state(turning) for the page in motion, and :state(revealed) for the page shown beneath it.

rc-page-turner-page:state(turning) {
box-shadow: 0 8px 24px rgb(0 0 0 / 0.2);
}

Keyboard​

With the page viewport focused, the arrow keys along the inline axis turn to the previous and next page, following dir and the writing mode, and Home/End go to the first and last page. Keys pressed inside page content, such as arrow keys in a text field, are left to that content.

Dragging and swiping​

Touch and pen drags along the inline axis turn pages; set mouse-dragging to let mouse drags turn them too (off by default, so a mouse can still select page text). A drag toward the inline start lifts the current page, and a drag toward the inline end brings the previous page in over it, edge-on at the spine from the first move. The page's free edge follows the pointer. On release the turn completes once that edge has crossed the middle of the page, or on a fast swipe toward it, and otherwise settles back. A completed drag reports rc-page-turner-change with trigger: 'swipe', and the click that ends a drag never reaches page content.

Drags across the inline axis stay with the browser, so a page's content still scrolls. touch-action stops at the nearest scroll container, though, so give a scrollable element inside a page its own touch-action: pan-y pinch-zoom (pan-x in vertical text), or the browser takes horizontal drags that start on it. Under reduced motion or forced colors a drag doesn't move the page: a long or fast enough one turns it with the usual fade.

Accessibility​

The host defaults to role="group" with aria-roledescription="pages"; give it an aria-label. Each page gets role="group", aria-roledescription="page", and an "N of M" aria-label unless it has its own label. The pages sit in a polite live region, so a turn announces the arriving page.

Accessibility state changes when a turn starts, not when it ends. The departing page becomes inert and aria-hidden at once. If focus was inside it, for example on a "done" checkbox that advances the turner, focus moves to the arriving page. Focus anywhere else, such as on the previous/next buttons, stays where it is.

Localization​

Built-in text follows the element's own lang, else <html lang>. Override it with messages, or register it for every instance under rc-page-turner: pageTurner, page, position, previous, next. See Localization.

API​

Properties​

PropertyMarkupTypeDefaultDescription
messagesJS property onlyPartial<RCPageTurnerMessages> | undefinedNot specifiedBuilt-in text overrides; unset keys fall back to registered text, then English.
navigationnavigationbooleanfalseShows previous/next buttons.
mouseDraggingmouse-draggingbooleanfalseLets mouse drags turn pages, as touch and pen drags always do.
activeIndexactive-indexnumberNot specifiedControls the current page. Host writes are silent.
defaultActiveIndexdefault-active-indexnumberNot specifiedInitial page for uncontrolled usage.
roleJS property onlystring'group'No description provided.

Methods​

MethodDescription
next()Turns to the next page.
previous()Turns to the previous page.
goToIndex(index: number, instant: unknown)Turns directly to a page index; `instant` skips the animation.

Events​

EventDetail typeDescription
rc-page-turner-changeNo detail type documentedFires when a button, a key, a swipe, or the imperative API asks for another page. Host writes to `activeIndex` are silent. `detail: { index, trigger: 'button'|'keyboard'|'swipe'|'api' }`

Slots​

NameDescription
(default)One or more `rc-page-turner-page` elements.
previous-iconOptional icon for the previous button, replacing the default chevron. Only rendered when `navigation` is set.
next-iconOptional icon for the next button, replacing the default chevron. Only rendered when `navigation` is set.

CSS Custom Properties​

PropertyDefaultDescription
--rc-page-turner-colorCanvasTextForeground color.
--rc-page-turner-duration320msLength of a turn. Read when a turn starts; a fade uses at most 150ms of it.
--rc-page-turner-easingcubic-bezier(0.2, 0, 0, 1)Easing of a turn. Read when a turn starts.
--rc-page-turner-perspective75remViewing distance a page turns in. Smaller values exaggerate the depth.
--rc-page-turner-page-back-backgroundCanvasThe blank back of a turning page, shown once it folds past 90°.
--rc-page-turner-navigation-button-size40pxPrevious/next button diameter.
--rc-page-turner-navigation-button-backgroundcolor-mix(in srgb, CanvasText 12%, transparent)Previous/next button background.
--rc-page-turner-navigation-button-colorCanvasTextPrevious/next button icon color.
--rc-page-turner-navigation-inset8pxPrevious/next button inset from the viewport edge.

CSS Parts​

PartDescription
viewportThe focusable page stack and live region.
page-backThe blank back of the turning page, a layer the turner draws in step with it. Visible only during a turn, once the page folds past 90°.
navigationPrevious/next button wrapper.
navigation-buttonA previous or next button.
navigation-button-previousThe previous button specifically.
navigation-button-nextThe next button specifically.

Properties​

PropertyMarkupTypeDefaultDescription
presentationJS property onlyRCPageTurnerPagePresentationNot specifiedCurrent presentation. Internal integration point for `rc-page-turner`.
roleJS property onlystring'group'No description provided.

Methods​

MethodDescription
present(presentation: RCPageTurnerPagePresentation, current: unknown)Applies a presentation synchronously, without a render pass, so a turn's writes land in the same frame as its animation. `current` is whether the page is the turner's current page, which alone decides whether it is reachable: a page turning in is current while it moves, a page turning away is not. Internal integration point for `rc-page-turner`, not a public API.
describe(label: string, roleDescription: string)Sets the default accessible name and role description. Internal integration point for `rc-page-turner`; an author `aria-label`, `aria-labelledby`, or `aria-roledescription` wins.

Slots​

NameDescription
(default)Page content.

CSS Custom Properties​

PropertyDefaultDescription
--rc-page-turner-page-backgroundCanvasPage surface. Keep it opaque: a turning page has to hide the page beneath it.
--rc-page-turner-page-colorCanvasTextPage foreground color.