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
- Yarn
- pnpm
npm install @rcarls/rc-webcomponents@next
yarn add @rcarls/rc-webcomponents@next
pnpm add @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
| Property | Markup | Type | Default | Description |
|---|---|---|---|---|
messages | JS property only | Partial<RCPageTurnerMessages> | undefined | Not specified | Built-in text overrides; unset keys fall back to registered text, then English. |
navigation | navigation | boolean | false | Shows previous/next buttons. |
mouseDragging | mouse-dragging | boolean | false | Lets mouse drags turn pages, as touch and pen drags always do. |
activeIndex | active-index | number | Not specified | Controls the current page. Host writes are silent. |
defaultActiveIndex | default-active-index | number | Not specified | Initial page for uncontrolled usage. |
role | JS property only | string | 'group' | No description provided. |
Methods
| Method | Description |
|---|---|
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
| Event | Detail type | Description |
|---|---|---|
rc-page-turner-change | No detail type documented | Fires 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
| Name | Description |
|---|---|
(default) | One or more `rc-page-turner-page` elements. |
previous-icon | Optional icon for the previous button, replacing the default chevron. Only rendered when `navigation` is set. |
next-icon | Optional icon for the next button, replacing the default chevron. Only rendered when `navigation` is set. |
CSS Custom Properties
| Property | Default | Description |
|---|---|---|
--rc-page-turner-color | CanvasText | Foreground color. |
--rc-page-turner-duration | 320ms | Length of a turn. Read when a turn starts; a fade uses at most 150ms of it. |
--rc-page-turner-easing | cubic-bezier(0.2, 0, 0, 1) | Easing of a turn. Read when a turn starts. |
--rc-page-turner-perspective | 75rem | Viewing distance a page turns in. Smaller values exaggerate the depth. |
--rc-page-turner-page-back-background | Canvas | The blank back of a turning page, shown once it folds past 90°. |
--rc-page-turner-navigation-button-size | 40px | Previous/next button diameter. |
--rc-page-turner-navigation-button-background | color-mix(in srgb, CanvasText 12%, transparent) | Previous/next button background. |
--rc-page-turner-navigation-button-color | CanvasText | Previous/next button icon color. |
--rc-page-turner-navigation-inset | 8px | Previous/next button inset from the viewport edge. |
CSS Parts
| Part | Description |
|---|---|
viewport | The focusable page stack and live region. |
page-back | The 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°. |
navigation | Previous/next button wrapper. |
navigation-button | A previous or next button. |
navigation-button-previous | The previous button specifically. |
navigation-button-next | The next button specifically. |
Properties
| Property | Markup | Type | Default | Description |
|---|---|---|---|---|
presentation | JS property only | RCPageTurnerPagePresentation | Not specified | Current presentation. Internal integration point for `rc-page-turner`. |
role | JS property only | string | 'group' | No description provided. |
Methods
| Method | Description |
|---|---|
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
| Name | Description |
|---|---|
(default) | Page content. |
CSS Custom Properties
| Property | Default | Description |
|---|---|---|
--rc-page-turner-page-background | Canvas | Page surface. Keep it opaque: a turning page has to hide the page beneath it. |
--rc-page-turner-page-color | CanvasText | Page foreground color. |