@roku-sdk/slideshow
v2.19.1
Published
Horizontal slideshow engine for Roku SceneGraph TypeScript apps. Supports eager, virtual, and pooled rendering modes with animated transitions, focus integration, and composable chrome (chevrons, progress dots).
Keywords
Readme
@roku-sdk/slideshow
Horizontal slideshow engine for Roku SceneGraph TypeScript apps. Supports eager, virtual, and pooled rendering modes with animated transitions, focus integration, and composable chrome (chevrons, progress dots).
Overview
@roku-sdk/slideshow provides a factory-function API (createSlideshow, createVirtualSlideshow, createPooledSlideshow) that mirrors the createFlexibleGrid pattern from @roku-sdk/flex-grid. Each factory returns an element you mount in your component tree plus imperative controls for programmatic navigation.
Key features:
- Three rendering modes: eager (all slides upfront), virtual (windowed), pooled (template pools with imperative reparenting)
- Smooth animation: SceneGraph
Animationnode via the Tween pattern — no JS animation loop - Focus integration: Factory creates an internal
FocusBoundary— supports both non-interactive and interactive slides out of the box - Composable chrome: swap in your own chevrons or dots, or use the defaults
- Tree-shakable exports: each mode and chrome component is a separate sub-path
Installation
pnpm add @roku-sdk/slideshowQuick Start
Eager Mode (Non-Interactive Slides)
Renders all slides at mount time. Best for small, fixed-count slideshows.
import { createSlideshow, FocusableSlide, DefaultChevrons, DefaultDotIndicator } from "@roku-sdk/slideshow";
const slideshowRef = {};
const { element } = createSlideshow({
slideCount: 5,
slideWidth: 1920,
slideHeight: 1080,
focusId: slideshowRef,
renderSlide: ({ index, focused, width, height, slideFocusId, goTo }) => (
<FocusableSlide slideFocusId={slideFocusId} onFocused={goTo}>
<rectangle width={width} height={height} color={focused() ? "0x0055FFFF" : "0x222222FF"} />
<label text={`Slide ${index + 1}`} translation={[40, 40]} color="0xFFFFFFFF" />
</FocusableSlide>
),
chevrons: DefaultChevrons,
dots: DefaultDotIndicator,
onSlideChange: (index) => console.log("Now on slide", index),
});
// Mount inside the app-root RootFocusBoundary:
return (
<RootFocusBoundary keyEvent={inputs.keyEvent} initialFocus={slideshowRef}>
{element()}
</RootFocusBoundary>
);Eager Mode (Interactive Slides)
Slides with buttons or other focusable content use a nested FocusBoundary:
import { createSlideshow, FocusableSlide } from "@roku-sdk/slideshow";
import { FocusBoundary, useFocusable } from "@roku-sdk/navigation";
const { element } = createSlideshow({
slideCount: 5,
slideWidth: 1920,
slideHeight: 1080,
focusId: slideshowRef,
renderSlide: ({ index, width, height, slideFocusId, goTo }) => {
if (index === 2) {
// Interactive slide — nested FocusBoundary with buttons
const btnRefs = [{}, {}];
return (
<group>
<rectangle width={width} height={height} color="0x0F3460FF" />
<FocusBoundary focusId={slideFocusId} initialFocus={btnRefs[0]} navigation="horizontal">
<MyButton label="Play" focusId={btnRefs[0]} onFocused={goTo} />
<MyButton label="Pause" focusId={btnRefs[1]} onFocused={goTo} />
</FocusBoundary>
</group>
);
}
// Non-interactive slide
return (
<FocusableSlide slideFocusId={slideFocusId} onFocused={goTo}>
<rectangle width={width} height={height} color="0x222222FF" />
</FocusableSlide>
);
},
});How it works: The factory's internal FocusBoundary (navigation="horizontal") navigates between slide positions on Left/Right. Interactive slides register as nested boundaries — keys dispatch into the slide first. When unhandled (e.g., Right on the last button), the key bubbles up and the factory advances to the next slide.
Virtual Mode
Only renders slides within windowSize of the current index. Best for large slide counts.
import { createVirtualSlideshow } from "@roku-sdk/slideshow/virtual";
import { FocusableSlide } from "@roku-sdk/slideshow";
const { element, currentIndex, goTo } = createVirtualSlideshow({
slideCount: 100,
slideWidth: 1920,
slideHeight: 1080,
focusId: slideshowRef,
windowSize: 2,
renderSlide: ({ index, width, height, slideFocusId, goTo }) => (
<FocusableSlide slideFocusId={slideFocusId} onFocused={goTo}>
<MySlideComponent index={index} width={width} height={height} />
</FocusableSlide>
),
});Pooled Mode
Uses a fixed pool of template nodes, reparented imperatively. Best for high-slide-count carousels with repeated content types.
import { createPooledSlideshow } from "@roku-sdk/slideshow/pooled";
const { element } = createPooledSlideshow({
slideCount: 200,
slideWidth: 400,
slideHeight: 300,
focusId: slideshowRef,
templates: {
standard: StandardSlideComponent,
featured: FeaturedSlideComponent,
},
getTemplateId: (index, item) => item?.featured ? "featured" : "standard",
poolSize: { standard: 5, featured: 2 },
items: myDataArray,
});Note: Pooled mode uses a leaf
useFocusablewithonKeyhandlers (rather than per-slide boundaries) because pool templates render insidecreateRootoutsideFocusBoundarycontext. Navigation still works — Left/Right scrolls between slides.
Chrome Sub-Path Imports
import { DefaultChevrons } from "@roku-sdk/slideshow/chrome/chevrons";
import { DefaultDotIndicator } from "@roku-sdk/slideshow/chrome/dots";
// Pass as props:
createSlideshow({ ..., chevrons: DefaultChevrons, dots: DefaultDotIndicator });
// Or disable chrome:
createSlideshow({ ..., chevrons: false, dots: false });JSX Wrapper
For purely declarative usage:
import { defineSlideshow, DefaultChevrons, DefaultDotIndicator } from "@roku-sdk/slideshow";
const MySlideshow = defineSlideshow<MyItemType>();
return (
<MySlideshow
slideCount={items.length}
slideWidth={1920}
slideHeight={1080}
items={items}
renderSlide={props => <MySlide {...props} />}
chevrons={DefaultChevrons}
dots={DefaultDotIndicator}
/>
);Focus Integration
The three factories differ here, and the difference decides how focus leaves the slideshow:
| Factory | Boundary it renders | Movement | At the first or last slide |
|---|---|---|---|
| createSlideshow | FocusBoundary with navigation="horizontal" | The boundary walks between slides | Fires onEdgeReached and keeps the key — call setFocus yourself |
| createVirtualSlideshow | FocusBoundary with no navigation | The factory calls prev() / next() and moves focus itself | Fires onEdgeReached and keeps the key — call setFocus yourself |
| createPooledSlideshow | none — a single leaf useFocusable | Its onKey handlers advance the index | Returns bubble, so the key bubbles and focus leaves on its own |
In eager and virtual modes each slide registers in the boundary via the slideFocusId prop passed
to renderSlide. Pooled templates receive PooledSlideRenderProps, which has no slideFocusId,
and render outside any boundary's context — they can never be focused. Put a slide that needs
focus in uniqueSlides.
renderSlide Props
Every renderSlide callback receives:
interface SlideRenderProps<T> {
index: number; // Slide position
item: T | undefined; // Data item (if items array provided)
focused: Accessor<boolean>; // True when this is the current slide
width: number; // Slide width
height: number; // Slide height
slideFocusId: object; // Focus ref — attach to your focusable
goTo: () => void; // Scrolls slideshow to THIS slide
}Two Patterns
Non-interactive slides: Use the FocusableSlide helper (calls useFocusable internally):
<FocusableSlide slideFocusId={slideFocusId} onFocused={goTo}>
{/* your slide content */}
</FocusableSlide>Interactive slides: Use slideFocusId as the focusId on your own <FocusBoundary>. Wire goTo into each button's onFocusGained so the strip scrolls when focus enters the slide:
<FocusBoundary focusId={slideFocusId} navigation="horizontal" initialFocus={btnRefs[0]}>
<button focusId={btnRefs[0]} onFocusGained={goTo} />
<button focusId={btnRefs[1]} onFocusGained={goTo} />
</FocusBoundary>focusId Prop
Pass a focusId object to the factory config. This registers the slideshow's internal boundary in whatever parent FocusBoundary you have at the app root:
const slideshowRef = {};
const { element } = createSlideshow({ ..., focusId: slideshowRef });
// App-root boundary uses slideshowRef as initialFocus:
<RootFocusBoundary keyEvent={inputs.keyEvent} initialFocus={slideshowRef}>
{element()}
</RootFocusBoundary>Imperative Handle
All factory functions return a SlideshowHandle<T>:
interface SlideshowHandle<T> {
element: () => JSX.Element; // Mount this (call it: {element()})
currentIndex: Accessor<number>; // Reactive current index
currentItem: Accessor<T | undefined>; // Reactive current item
goTo(index: number, animated?: boolean): void;
next(): void;
prev(): void;
getCurrentIndex(): number;
updateSlides(startIndex: number, items: (T | undefined)[]): void;
setSlideCount(n: number): void;
}Package Exports
| Import path | What you get |
|---|---|
| @roku-sdk/slideshow | createSlideshow, FocusableSlide, defineSlideshow, Slideshow, DefaultChevrons, DefaultDotIndicator, types |
| @roku-sdk/slideshow/virtual | createVirtualSlideshow |
| @roku-sdk/slideshow/pooled | createPooledSlideshow, createSlideshowPool, pool types |
| @roku-sdk/slideshow/chrome/chevrons | DefaultChevrons |
| @roku-sdk/slideshow/chrome/dots | DefaultDotIndicator |
Configuration
Common Options (all modes)
| Option | Type | Default | Description |
|---|---|---|---|
| slideCount | number | required | Total number of slides |
| slideWidth | number | required | Width of each slide (px) |
| slideHeight | number | required | Height of each slide (px) |
| margin | number | 0 | Gap between slides (px) |
| translation | [number, number] | [0, 0] | Position offset of the slideshow container |
| focusId | object | undefined | Registers the slideshow in a parent FocusBoundary |
| initialIndex | number | 0 | Starting slide index |
| items | T[] | undefined | Data array (passed to renderSlide as item) |
| renderSlide | (props: SlideRenderProps<T>) => JSX.Element | undefined | Slide renderer |
| chevrons | Component \| false | undefined | Chevron chrome component (or false to disable) |
| dots | Component \| false | undefined | Dot indicator component (or false to disable) |
| animation | { durationSec?, easeFunction? } | { durationSec: 0.333, easeFunction: "outCubic" } | Tween animation config |
| onSlideChange | (index, item?) => void | undefined | Fires when current slide changes |
| onEdgeReached | (edge: "first" \| "last") => void | undefined | Fires when navigating past the first/last slide |
| onSelect | (index, item?) => void | undefined | Fires on OK press |
createVirtualSlideshow Options
All common options, plus:
| Option | Type | Default | Description |
|---|---|---|---|
| windowSize | number | 1 | Number of slides to render on each side of current |
createPooledSlideshow Options
All common options, plus:
| Option | Type | Description |
|---|---|---|
| templates | Record<string, Component> | Template components keyed by template ID |
| getTemplateId | (index, item?) => string | Returns template ID for a given slide |
| poolSize | Record<string, number> | Initial pool size per template |
| uniqueSlides | number[] \| ((index) => boolean) | Indices that bypass pooling entirely and render eagerly through renderSlide. The only slides a pooled slideshow can make focusable |
Examples
See examples/slideshow/ in the workspace for runnable examples:
- eager — Basic non-interactive slideshow with chevrons and dots
- virtual — Large slide count with windowed rendering
- pooled — Template pool with multiple slide types
- focus — Mixed interactive + non-interactive slides using the factory's focus integration
Run any example:
cd examples/slideshow
npx rk dev --config roku-config-eager.ts --host <device-ip> --password <pwd> --no-typecheck