Reel Player
A full-screen Instagram-Reel/TikTok-style video player component using @reelkit/react-reel-player.
Features
Installation
npm install @reelkit/react-reel-player @reelkit/react lucide-reactDon't forget to import the styles:
import '@reelkit/react-reel-player/styles.css';Icons
The default controls use lucide-react for icons. If you prefer a different icon library, use renderControls and renderNavigation to provide your own.
Quick Start
The ReelPlayerOverlay component renders a full-screen player overlay. Pass an array of ContentItem objects and control visibility with isOpen.
import { useState } from 'react';
import { ReelPlayerOverlay, type ContentItem } from '@reelkit/react-reel-player';
import '@reelkit/react-reel-player/styles.css';
const content: ContentItem[] = [
{
id: '1',
media: [{
id: 'v1',
type: 'video',
src: 'https://example.com/video.mp4',
poster: 'https://example.com/poster.jpg',
aspectRatio: 9 / 16,
}],
author: { name: 'John Doe', avatar: 'https://example.com/avatar.jpg' },
likes: 1234,
description: 'Amazing video!',
},
];
function App() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button onClick={() => setIsOpen(true)}>Open Player</button>
<ReelPlayerOverlay
isOpen={isOpen}
onClose={() => setIsOpen(false)}
content={content}
/>
</>
);
}import { useState } from 'react';
import { ReelPlayerOverlay, type ContentItem } from '@reelkit/react-reel-player';
import '@reelkit/react-reel-player/styles.css';
const content: ContentItem[] = [
{
id: '1',
media: [{
id: 'v1',
type: 'video',
src: '/cdn/samples/videos/video-01.mp4',
poster: '/cdn/samples/videos/video-poster-01.jpg',
aspectRatio: 16 / 9,
}],
author: { name: 'Alex Johnson', avatar: '/cdn/samples/avatars/avatar-01.jpg' },
likes: 1234,
description: 'Amazing sunset vibes',
},
{
id: '2',
media: [{
id: 'img1',
type: 'image',
src: '/cdn/samples/images/image-01.jpg',
aspectRatio: 2 / 3,
}],
author: { name: 'Sarah Miller', avatar: '/cdn/samples/avatars/avatar-02.jpg' },
likes: 5678,
description: 'Nature at its finest',
},
{
id: '3',
media: [{
id: 'v2',
type: 'video',
src: '/cdn/samples/videos/video-04.mp4',
poster: '/cdn/samples/videos/video-poster-04.jpg',
aspectRatio: 16 / 9,
}],
author: { name: 'James Wilson', avatar: '/cdn/samples/avatars/avatar-03.jpg' },
likes: 3456,
description: 'City life adventures',
},
{
id: '4',
media: [
{ id: 'img2', type: 'image', src: '/cdn/samples/images/image-02.jpg', aspectRatio: 2 / 3 },
{ id: 'img3', type: 'image', src: '/cdn/samples/images/image-03.jpg', aspectRatio: 3 / 4 },
],
author: { name: 'Emma Davis', avatar: '/cdn/samples/avatars/avatar-04.jpg' },
likes: 8901,
description: 'Travel moments',
},
{
id: '5',
media: [{
id: 'img4',
type: 'image',
src: '/cdn/samples/images/image-04.jpg',
aspectRatio: 2 / 3,
}],
author: { name: 'Michael Brown', avatar: '/cdn/samples/avatars/avatar-05.jpg' },
likes: 2345,
description: 'Golden hour magic',
},
{
id: '6',
media: [{
id: 'v3',
type: 'video',
src: '/cdn/samples/videos/video-05.mp4',
poster: '/cdn/samples/videos/video-poster-05.jpg',
aspectRatio: 16 / 9,
}],
author: { name: 'Alex Johnson', avatar: '/cdn/samples/avatars/avatar-01.jpg' },
likes: 7890,
description: 'Living the moment',
},
];
export default function App() {
const [isOpen, setIsOpen] = useState(false);
const [initialIndex, setInitialIndex] = useState(0);
return (
<div style={{ padding: 16, background: '#0f172a', minHeight: '100vh' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 6 }}>
{content.map((item, i) => (
<button
key={item.id}
onClick={() => { setInitialIndex(i); setIsOpen(true); }}
style={{
position: 'relative', aspectRatio: '9 / 16', borderRadius: 8,
overflow: 'hidden', border: 'none', padding: 0, cursor: 'pointer',
background: '#1e293b',
}}
>
<img
src={item.media[0].poster || item.media[0].src}
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
</button>
))}
</div>
<ReelPlayerOverlay
isOpen={isOpen}
onClose={() => setIsOpen(false)}
content={content}
initialIndex={initialIndex}
/>
</div>
);
}For the whole flow, a thumbnail grid that opens the player and brings the viewer back to where they stopped, see the Grid to Player recipe.
Customization
Generic Content Type
Use custom data types by extending BaseContentItem:
import { ReelPlayerOverlay, type BaseContentItem } from '@reelkit/react-reel-player';
interface MyItem extends BaseContentItem {
title: string;
username: string;
}
const items: MyItem[] = [
{
id: '1',
media: [{ id: 'v1', type: 'video', src: '/video.mp4', aspectRatio: 9/16 }],
title: 'My Video',
username: '@user',
},
];
<ReelPlayerOverlay<MyItem>
isOpen={isOpen}
onClose={handleClose}
content={items}
renderSlideOverlay={(item) => (
<div style={{ position: 'absolute', bottom: 16, left: 16, color: '#fff' }}>
<strong>{item.username}</strong>
<p>{item.title}</p>
</div>
)}
/>Custom Slide Overlay
Replace the built-in slide overlay with custom content per slide:
<ReelPlayerOverlay
isOpen={isOpen}
onClose={handleClose}
content={content}
renderSlideOverlay={(item, index, isActive) => (
<div style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
padding: 16,
background: 'linear-gradient(transparent, rgba(0,0,0,0.8))',
color: '#fff',
}}>
<h3>{item.author.name}</h3>
<p>{item.description}</p>
<span>Slide {index + 1} {isActive ? '(active)' : ''}</span>
</div>
)}
/>Non-Media Slides
Use renderSlide to inject custom content (e.g., CTA cards). Return null to fall back to default:
<ReelPlayerOverlay
isOpen={isOpen}
onClose={handleClose}
content={content}
renderSlide={({ index, size }) => {
// CTA card on last slide
if (index === content.length - 1) {
return (
<div style={{
width: size[0],
height: size[1],
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #667eea, #764ba2)',
color: '#fff',
}}>
<div style={{ textAlign: 'center' }}>
<h2>Follow for more!</h2>
<button>Subscribe</button>
</div>
</div>
);
}
// Fall back to default MediaSlide + overlay
return null;
}}
/>Custom Controls
Compose reusable sub-components with your own additions:
import {
ReelPlayerOverlay,
CloseButton,
SoundButton,
} from '@reelkit/react-reel-player';
<ReelPlayerOverlay
isOpen={isOpen}
onClose={handleClose}
content={content}
renderControls={({ onClose, content, activeIndex }) => (
<>
<CloseButton onClick={onClose} />
<SoundButton />
<button
onClick={() => share(content[activeIndex])}
style={{
position: 'absolute',
bottom: 60,
right: 16,
zIndex: 10,
}}
>
Share
</button>
</>
)}
/>Custom Timeline
Replace the built-in playback bar with your own scrub UI via renderTimeline. The callback only fires when the overlay's gating rules would render the default bar (same timeline mode + timelineMinDurationSeconds logic), so you don't re-implement it. Reuse the .rk-reel-timeline class on your root to inherit flush-bottom positioning, safe-area padding, and touch-device slide-overlay clearance.
import { useRef, useEffect } from 'react';
import { ReelPlayerOverlay } from '@reelkit/react-reel-player';
import { Observe } from '@reelkit/react';
function CustomTimelineBar({ timelineState }) {
const trackRef = useRef(null);
useEffect(() => {
if (!trackRef.current) return;
// Pointer + keyboard scrub wiring, same as the built-in bar.
return timelineState.bindInteractions(trackRef.current);
}, [timelineState]);
return (
<div className="rk-reel-timeline" style={{ padding: '0 16px' }}>
<Observe signals={[timelineState.progress, timelineState.currentTime]}>
{() => (
<div
ref={trackRef}
role="slider"
aria-valuenow={timelineState.currentTime.value}
style={{ height: 6, background: 'rgba(255,255,255,0.2)' }}
>
<div style={{
width: `${timelineState.progress.value * 100}%`,
height: '100%',
background: 'linear-gradient(90deg, #6366f1, #ec4899)',
}} />
</div>
)}
</Observe>
</div>
);
}
<ReelPlayerOverlay
isOpen={isOpen}
onClose={handleClose}
content={content}
timeline="always"
renderTimeline={({ timelineState }) => (
<CustomTimelineBar timelineState={timelineState} />
)}
/>Custom Navigation
<ReelPlayerOverlay
isOpen={isOpen}
onClose={handleClose}
content={content}
renderNavigation={({ onPrev, onNext, activeIndex, count }) => (
<div style={{ position: 'absolute', right: 16, top: '50%', transform: 'translateY(-50%)' }}>
<button onClick={onPrev} disabled={activeIndex === 0}>Up</button>
<span>{activeIndex + 1}/{count}</span>
<button onClick={onNext} disabled={activeIndex === count - 1}>Down</button>
</div>
)}
/>Custom Nested Navigation
Replace the left/right arrows inside multi-media slides (horizontal carousel) with custom navigation:
<ReelPlayerOverlay
isOpen={isOpen}
onClose={handleClose}
content={content}
renderNestedNavigation={({ onPrev, onNext, activeIndex, count }) => (
<div style={{
position: 'absolute',
bottom: 48,
left: 0,
right: 0,
display: 'flex',
justifyContent: 'center',
gap: 8,
zIndex: 10,
}}>
<button onClick={onPrev} disabled={activeIndex === 0}>Prev</button>
<span>{activeIndex + 1} / {count}</span>
<button onClick={onNext} disabled={activeIndex === count - 1}>Next</button>
</div>
)}
/>Custom Nested Slides
Customise individual slides inside multi-media carousels with renderNestedSlide. Use props.defaultContent to wrap the default ImageSlide/VideoSlide, or replace it entirely:
// Wrap default slides with rounded corners
<ReelPlayerOverlay
isOpen={isOpen}
onClose={handleClose}
content={content}
renderNestedSlide={({ defaultContent }) => (
<div style={{ borderRadius: 16, overflow: 'hidden' }}>
{defaultContent}
</div>
)}
/>
// Fully custom nested slide for images
<ReelPlayerOverlay
isOpen={isOpen}
onClose={handleClose}
content={content}
renderNestedSlide={({ item, size, isActive, slideKey, onVideoRef, defaultContent }) => {
if (item.type === 'video') return defaultContent; // keep default video
return (
<ImageSlide
src={item.src}
size={size}
imgStyle={{ objectFit: 'contain' }}
style={{ backgroundColor: '#111' }}
/>
);
}}
/>URL State
View live demo →ReelPlayerUrlOverlay is a separate component whose open state lives in the address bar. Build a controller with useOverlayUrlState from @reelkit/react and hand it over as controller: the player opens itself when the parameter names a slide and closes when it goes away. Links are shareable, and the back button closes the player instead of leaving the page.
Built-in keys
You can address slides with a built-in key — spread urlIndexKey (by position) or urlStableIdKey (by a stable id) into the controller — both re-exported from @reelkit/react. See the URL State guide and Core API.
import { useOverlayUrlState, urlIndexKey, urlStableIdKey } from '@reelkit/react';
import { ReelPlayerUrlOverlay } from '@reelkit/react-reel-player';
import { Link } from 'react-router-dom';
const reel = useOverlayUrlState({
param: 'reel',
...urlIndexKey(() => content.length),
});
// Opening is a link — the overlay reads the URL and opens itself.
{content.map((item, i) => (
<Link key={item.id} to={`?reel=${i}`}>
<img src={getThumbnail(item)} />
</Link>
))}
<ReelPlayerUrlOverlay controller={reel} content={content} />Full useOverlayUrlState options — param, adapter, codec, locator — live in the React API reference. The walkthrough is in the React guide.
- Opening pushes one history entry. Swiping the feed replaces it, so N swipes add no entries and one back step always leaves the player. Back closes; it does not step slides.
- Back closes only when the player was opened from within the app — the link pushed an entry. A shared link opened directly in a fresh tab has no history behind it, so browser-back leaves the site; the ✕ button or Escape removes the parameter in place and stays.
- A deep link
?reel=3opens the player at that slide on load. - A parameter naming no slide — a stale bookmark, a hand-edited value — is dropped from the URL rather than leaving the address bar asserting a slide that cannot open.
- By default the parameter addresses the vertical post only (
?reel=3). Opt into a two-axis key to also carry the inner media index of a multi-media carousel — see below.
One key or two — pick your URL depth
The same ReelPlayerUrlOverlay drives either shape; it discriminates at runtime from the controller's position, so there is no mode prop. Choose the key when you build the controller:
| Key | Wire | Carries |
|---|---|---|
urlIndexKey(…) | ?reel=3 | The vertical post only. |
urlIndexTwoAxisKey(…) | ?reel=3.2 | The post and the inner media index of a carousel. |
The two wires are deliberately distinct — a two-axis key is strictly dotted (3.0, never a bare 3), so a bare one-axis link does not cross-decode. Switching an app between keys therefore invalidates any previously shared links. Pick one shape and keep it.
import { useOverlayUrlState, urlIndexTwoAxisKey } from '@reelkit/react';
const reel = useOverlayUrlState({
param: 'reel',
...urlIndexTwoAxisKey({
outerCount: () => content.length,
innerCounts: () => content.map((post) => post.media.length),
}),
});
// A link now names both axes: post 3, inner media 2.
<Link to="?reel=3.2">…</Link>
<ReelPlayerUrlOverlay controller={reel} content={content} />Routed app — pass an adapter. Writing history.pushState behind a router leaves its location stale, and its next navigation drops the parameter:
import { useReactRouterUrlAdapter } from '@reelkit/react/react-router-url-adapter';
const adapter = useReactRouterUrlAdapter();
const reel = useOverlayUrlState({
param: 'reel',
adapter,
...urlIndexKey(() => content.length),
});
<ReelPlayerUrlOverlay controller={reel} content={content} />Stable links. The index is positional, so a bookmarked ?reel=3 opens a different post once the feed is reordered — for a feed that is the normal case, not the exception. urlStableIdKey keys by each post's stable id, scanning the live feed — one call covers the common case.
const reel = useOverlayUrlState({
param: 'reel',
...urlStableIdKey({ items: () => content }),
});Pass hashCodec: base64UrlCodec to base64url-encode the id in the URL — reversible obfuscation, not a cryptographic hash.
Key by a different field (a slug), or page an infinite feed with locateAsync, and build the codec/locator yourself. Two separate jobs: codec spells the identity into the URL, locator finds where that identity sits.
const reel = useOverlayUrlState({
param: 'reel',
codec: { decode: (raw) => raw, encode: (id) => id },
locator: {
locate: (id) => content.findIndex((x) => x.id === id),
identify: (index) => content[index].id,
},
});Infinite feeds. locate is synchronous, so it can only answer for posts already loaded — a shared link to post 400 of a feed that has loaded 20 comes up empty. locateAsync is the fallback, called only when locate misses.
Shortcut
Keying by the item’s id? Skip the hand-rolled codec and locator — pass locateAsync straight to urlStableIdKey({ items, locateAsync }) (it fetches on a miss, then returns the index). The fuller version below is for keying by another field, or for full control.
const reel = useOverlayUrlState({
param: 'reel',
codec: { decode: (raw) => raw, encode: (id) => id },
locator: {
locate: (id) => content.findIndex((x) => x.id === id),
identify: (index) => content[index].id,
locateAsync: async (id) => {
const loaded = await loadById(id); // or loadUntil(id) — fetch just that one, or page up to it
if (!loaded) return null; // exhausted — link names no post
setContent(loaded); // commit — the overlay renders from this state
return loaded.findIndex((x) => x.id === id); // wherever it landed
},
},
});- While
locateAsyncis pending the player stays closed and the parameter is left alone, so the deep link survives the fetch.nullor a rejection drops the parameter. - An answer arriving after the URL moved on, after a close, or after unmount is discarded — a slow fetch cannot open a slide nobody asked for.
- Nothing is rendered while pending; the page already owns that loading state, so render your own skeleton.
- There is no timeout — the player cannot know how long the feed is. Settle with
nullwhen pagination is exhausted, or the overlay stays closed indefinitely.
API Reference
ReelPlayerOverlayProps Props
ReelPlayerOverlayProps<T>
ReelPlayerOverlayProps is ReelPlayerOverlayBaseProps (the visual and behaviour props both overlays share) joined with ReelPlayerControlledProps (isOpen and onClose). Use the base type to type a wrapper that renders either overlay.
| Prop | Type | Default | Description |
|---|---|---|---|
apiRef | MutableRefObject<ReelApi> | - | Ref to access Reel API |
ariaLabel | string | 'Video player' | Accessible label for the dialog region; announced by screen readers when the overlay opens |
aspectRatio | number | 9/16 (0.5625) | Width/height ratio for the player container on desktop. At 768px wide and narrower the player always uses the full viewport. A zero, negative or non-finite value falls back to 9/16. |
content | T[] | required | Array of content items (generic, defaults to ContentItem) |
initialIndex | number | 0 | Starting slide index |
initialInnerIndex | number | 0 | Inner media index to open at, for the initially visible post only — lets a two-axis URL deep-link into a specific image of a multi-media post. Ignored once the player is open and the user navigates. |
isOpen | boolean | required | Controls overlay visibility. For URL-driven open state, reach for the separate ReelPlayerUrlOverlay instead — see URL State below. |
timeline | 'auto' | 'always' | 'never' | 'auto' | Gating strategy for the built-in playback timeline bar. 'auto' renders only for videos longer than timelineMinDurationSeconds; 'always' renders whenever the active slide has a video; 'never' disables the built-in bar (use renderTimeline for a fully custom replacement). |
timelineMinDurationSeconds | number | 30 | Minimum video duration (seconds) for timeline='auto' to render the built-in bar. Short looping clips below this threshold are suppressed. |
renderControls | (props: ControlsRenderProps) => ReactNode | - | Custom controls, replaces default close + sound buttons |
renderError | (props: { item: T; activeIndex: number }) => ReactNode | - | Custom error indicator, replaces default error icon |
renderLoading | (props: { item: T; activeIndex: number }) => ReactNode | - | Custom loading indicator, replaces default wave loader |
renderNavigation | (props: NavigationRenderProps) => ReactNode | - | Custom navigation, replaces default vertical arrows |
renderNestedNavigation | (props: NavigationRenderProps) => ReactNode | - | Custom navigation for nested horizontal slider (multi-media posts), replaces default left/right arrows |
renderNestedSlide | (props: NestedSlideRenderProps) => ReactNode | - | Custom slide renderer for nested horizontal slider items. Use props.defaultContent to wrap or embed the default ImageSlide/VideoSlide. Unlike renderSlide, null is not treated as a fallback. |
renderSlide | (props: SlideRenderProps) => ReactNode | null | - | Custom slide rendering. Return null to fall back to default. Use props.defaultContent to wrap or embed the default slide. |
renderSlideOverlay | (item, index, isActive) => ReactNode | - | Custom overlay per slide, replaces default SlideOverlay. Return null to hide. |
renderTimeline | (props: TimelineRenderProps) => ReactNode | - | Custom playback timeline bar. Invoked only when gating rules would render the default bar (same auto/always/never + timelineMinDurationSeconds logic). Use props.defaultContent to wrap the built-in <TimelineBar />; return null to hide. |
ReelPlayerUrlOverlay Props
ReelPlayerUrlOverlayProps<T>
Takes every visual and behaviour prop above except isOpen, replaced by a controller: the type is ReelPlayerOverlayBaseProps joined with ReelPlayerUrlControlledProps. initialIndex is ignored — the controller's position picks the slide, so a value passed alongside it is overwritten on every open.
| Prop | Type | Default | Description |
|---|---|---|---|
controller | UrlStateController | required | Controller from useOverlayUrlState. Its position decides whether the overlay is open and which slide it shows; the overlay writes back through it on slide change and on close. |
Callbacks
| Prop | Type | Description |
|---|---|---|
onClose | () => void | Called when the player closes. Required on ReelPlayerOverlay (you own the open state, so you must handle closing); optional on ReelPlayerUrlOverlay, where the URL drives closing — pass it only to react after close. |
onSlideChange | (index: number) => void | Called after slide change |
onInnerSlideChange | (outerIndex: number, innerIndex: number) => void | Called when the active post's inner media index changes — on inner navigation within a multi-media post, and on outer activation, reporting the activated post's current inner index (0 for a single-media post). |
Reel Props (proxied)
These props are forwarded to the underlying Reel component. Their type is ReelProxyProps, the subset of the Reel props the overlay passes through; use it to type a wrapper that forwards them.
| Prop | Type | Default | Description |
|---|---|---|---|
enableNavKeys | boolean | true | Enable keyboard navigation |
enableWheel | boolean | true | Enable mouse wheel navigation |
loop | boolean | false | Enable infinite loop |
swipeDistanceFactor | number | 0.12 | Swipe threshold (0-1) |
transitionDuration | number | 300 | Transition animation duration (ms) |
wheelDebounceMs | number | 200 | Wheel debounce duration (ms) |
Types
BaseContentItem
The generic constraint type. Extend this to use custom data types with ReelPlayerOverlay.
interface BaseContentItem {
id: string;
media: MediaItem[];
}ContentItem
interface ContentItem extends BaseContentItem {
author: {
name: string;
avatar: string;
};
likes: number;
description: string;
}MediaItem
interface MediaItem {
id: string;
type: 'image' | 'video';
src: string;
poster?: string;
aspectRatio: number;
}MediaType
type MediaType = 'image' | 'video';ControlsRenderProps<T>
interface ControlsRenderProps<T extends BaseContentItem> {
onClose: () => void;
soundState: SoundState;
activeIndex: number;
content: T[];
}NavigationRenderProps
interface NavigationRenderProps {
onPrev: () => void;
onNext: () => void;
activeIndex: number;
count: number;
}SlideRenderProps<T>
interface SlideRenderProps<T extends BaseContentItem> {
item: T;
index: number;
size: [number, number];
isActive: boolean;
slideKey: string;
onVideoRef?: (ref: HTMLVideoElement | null) => void;
innerSliderRef: MutableRefObject<ReelApi | null>;
onActiveMediaTypeChange?: (type: 'image' | 'video') => void;
renderNestedNavigation?: (props: NavigationRenderProps) => ReactNode;
enableWheel?: boolean;
defaultContent: ReactNode;
}NestedSlideRenderProps
interface NestedSlideRenderProps {
item: MediaItem;
index: number;
size: [number, number];
isActive: boolean;
isInnerActive: boolean;
slideKey: string;
onVideoRef?: (ref: HTMLVideoElement | null) => void;
defaultContent: ReactNode;
}SlideOverlayProps
interface SlideOverlayProps {
author?: { name: string; avatar: string };
description?: string;
likes?: number;
}ImageSlideProps
interface ImageSlideProps {
src: string;
size: [number, number];
className?: string;
style?: CSSProperties;
imgClassName?: string;
imgStyle?: CSSProperties;
}VideoSlideProps
interface VideoSlideProps {
src: string;
poster?: string;
aspectRatio: number;
size: [number, number];
isActive: boolean;
isInnerActive?: boolean; // default: true
slideKey: string;
onVideoRef?: (ref: HTMLVideoElement | null) => void;
className?: string;
style?: CSSProperties;
}CloseButtonProps
interface CloseButtonProps {
onClick: () => void;
className?: string;
style?: React.CSSProperties;
}SoundButtonProps
interface SoundButtonProps {
disabled?: boolean;
className?: string;
style?: React.CSSProperties;
}TimelineBarProps
interface TimelineBarProps {
className?: string;
style?: React.CSSProperties;
}TimelineRenderProps<T>
interface TimelineRenderProps<T extends BaseContentItem> {
item: T;
activeIndex: number;
timelineState: TimelineController;
defaultContent: ReactNode;
}Sub-Components
Reusable building blocks exported for composition in custom render props:
CloseButton
Standalone close button with default reel-player styling. Use inside renderControls.
import { CloseButton } from '@reelkit/react-reel-player';
<CloseButton onClick={onClose} />
<CloseButton onClick={onClose} className="my-close-btn" style={{ top: 24, right: 24 }} />SoundButton
Standalone sound toggle. Must be inside a SoundProvider (automatically provided by ReelPlayerOverlay).
import { SoundButton } from '@reelkit/react-reel-player';
<SoundButton />
<SoundButton disabled className="my-sound-btn" />TimelineBar
Default playback scrub bar. Reads from the nearest TimelineProvider (automatically mounted inside ReelPlayerOverlay) and renders the track, buffered ranges, progress fill, and scrub pill. Theme via the --rk-reel-timeline-* custom properties, or replace via renderTimeline.
import { TimelineBar } from '@reelkit/react-reel-player';
// Inside renderTimeline — wrap or augment the default bar:
<ReelPlayerOverlay
renderTimeline={({ defaultContent }) => (
<>
<MyTimecode />
{defaultContent}
</>
)}
/>
// Or render standalone inside a custom TimelineProvider tree:
<TimelineBar className="my-timeline" />SlideOverlay
The default gradient overlay showing author, description, and likes. Rendered automatically when content has the required fields. Use renderSlideOverlay to replace or hide it.
import { SlideOverlay } from '@reelkit/react-reel-player';
<SlideOverlay
author={{ name: 'John', avatar: '/avatar.jpg' }}
description="Amazing content"
likes={12500}
/>ImageSlide
Image slide with lazy loading and object-fit: cover by default. Use inside renderSlide to compose custom image slides with your own styles.
import { ImageSlide } from '@reelkit/react-reel-player';
// Default usage
<ImageSlide src="/photo.jpg" size={[400, 700]} />
// Custom styles
<ImageSlide
src="/photo.jpg"
size={[400, 700]}
className="my-image-slide"
style={{ backgroundColor: '#1a1a1a', borderRadius: 12 }}
imgStyle={{ objectFit: 'contain' }}
/>VideoSlide
Video slide with shared <video> element for iOS sound continuity, poster frames, position memory, and loading indicator. Must be inside a SoundProvider (automatically provided by ReelPlayerOverlay).
import { VideoSlide } from '@reelkit/react-reel-player';
<VideoSlide
src="/video.mp4"
poster="/thumb.jpg"
aspectRatio={9 / 16}
size={[400, 700]}
isActive={true}
slideKey="slide-1"
style={{ borderRadius: 12 }}
/>Composing custom slides
Use renderSlide with ImageSlide / VideoSlide to customize media rendering while keeping all built-in behavior (autoplay, poster capture, sound sync).
import {
ReelPlayerOverlay,
ImageSlide,
VideoSlide,
} from '@reelkit/react-reel-player';
<ReelPlayerOverlay
isOpen={isOpen}
onClose={handleClose}
content={content}
renderSlide={({ item, size, isActive, slideKey, onVideoRef }) => {
const media = item.media[0];
if (media.type === 'image') {
return (
<ImageSlide
src={media.src}
size={size}
imgStyle={{ objectFit: 'contain' }}
style={{ backgroundColor: '#111' }}
/>
);
}
if (media.type === 'video') {
return (
<VideoSlide
src={media.src}
poster={media.poster}
aspectRatio={media.aspectRatio}
size={size}
isActive={isActive}
slideKey={slideKey}
onVideoRef={onVideoRef}
style={{ borderRadius: 16 }}
/>
);
}
return null; // fallback to default
}}
/>Content Loading & Error Handling
The player tracks per-slide loading and error states. A wave loader shows while content loads; an error icon shows for broken media. Errored URLs are cached so revisiting shows the error instantly without retrying.
Lifecycle Callbacks
When using renderSlide, call these callbacks to control the loading indicator:
| Callback | When to call |
|---|---|
onReady | Image loaded or video started playing. Clears loading and error states. |
onWaiting | Video is buffering mid-playback. Shows the loading indicator. |
onError | Content failed to load. Shows error overlay and caches the URL as broken. |
// Inside renderSlide — wire callbacks to your custom media
renderSlide={({ item, size, isActive, onReady, onWaiting, onError }) => (
<div style={{ width: size[0], height: size[1] }}>
{item.media[0].type === 'image' ? (
<img
src={item.media[0].src}
onLoad={onReady}
onError={onError}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
<video
src={item.media[0].src}
autoPlay={isActive}
onCanPlay={onReady}
onWaiting={onWaiting}
onError={onError}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
)}
</div>
)}Custom Loading & Error UI
Replace the default wave loader and error icon with custom components:
<ReelPlayerOverlay
isOpen={isOpen}
onClose={() => setIsOpen(false)}
content={content}
renderLoading={({ item, activeIndex }) => (
<div style={{
position: 'absolute', inset: 0, zIndex: 10,
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#fff', fontSize: 14,
}}>
Loading slide {activeIndex + 1}...
</div>
)}
renderError={({ item, activeIndex }) => (
<div style={{
position: 'absolute', inset: 0, zIndex: 10,
display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
gap: 12, color: 'rgba(255,255,255,0.5)',
}}>
<span style={{ fontSize: 48 }}>!</span>
<span>Failed to load media</span>
</div>
)}
/>Timeline
The overlay renders a built-in playback timeline bar over the active video. The timeline prop, of type TimelineMode, gates rendering:
'auto'(default): renders when the active media is a video whose duration exceedstimelineMinDurationSeconds(default 30). Works for single-video slides and multi-media carousels; the bar follows the active nested item and hides on images.'always': renders whenever the active slide has a video.'never': never renders. Build a custom bar viarenderTimeline.
<ReelPlayerOverlay
isOpen={isOpen}
onClose={close}
content={items}
timeline="auto"
timelineMinDurationSeconds={30}
/>Theme via the --rk-reel-timeline-* CSS custom properties (height, colors, cursor size). For a fully custom scrub bar, timecode, or progress indicator, use renderTimeline; the callback receives a timelineState backed by the underlying TimelineController.
A component rendered inside the overlay can read the same controller itself: useTimelineState() returns it from the nearest TimelineProvider (mounted by the overlay) and throws outside one; useTimelineStateOptional() returns null there instead, for components that may also render outside the player.
Sound Context
For custom implementations, you can access the sound state:
import { SoundProvider, useSoundState } from '@reelkit/react';
// ReelPlayerOverlay wraps itself in a SoundProvider.
// Access sound state inside custom controls:
function CustomControls() {
const soundState = useSoundState();
return (
<button onClick={soundState.toggle}>
{soundState.muted.value ? 'Unmute' : 'Mute'}
</button>
);
}CSS Classes
All CSS classes are plain (not CSS modules), so they can be targeted with higher-specificity selectors in a stylesheet loaded after @reelkit/react-reel-player/styles.css. For color, size, and z-index changes, prefer the CSS custom properties documented in the Theming section below — they're designed for exactly that.
| Class | Component | Description |
|---|---|---|
.rk-reel-overlay | Overlay | Fixed full-screen backdrop (background, z-index) |
.rk-reel-container | Overlay | Player container (position, overflow) |
.rk-reel-loader | Overlay | Wave loading animation overlay |
.rk-reel-media-error | Overlay | Error state overlay (centered icon + text) |
.rk-reel-media-error-text | Overlay | Error message text |
.rk-reel-button | Controls | Shared circular icon button (close, sound, nav arrows) |
.rk-reel-close-btn | Controls | Close button |
.rk-reel-sound-btn | Controls | Sound toggle button |
.rk-reel-nav-arrows | Navigation | Desktop-only arrow container (hidden at 768px wide and narrower) |
.rk-reel-nav-button | Navigation | Individual prev/next nav arrow |
.rk-reel-slide-wrapper | Slide | Wrapper around media + overlay |
.rk-reel-slide-overlay | SlideOverlay | Gradient overlay container |
.rk-reel-slide-overlay-author | SlideOverlay | Author row (avatar + name) |
.rk-reel-slide-overlay-avatar | SlideOverlay | Author avatar image |
.rk-reel-slide-overlay-name | SlideOverlay | Author name text |
.rk-reel-slide-overlay-description | SlideOverlay | Description text |
.rk-reel-slide-overlay-likes | SlideOverlay | Likes row (heart + count) |
.rk-reel-video-container | VideoSlide | Video wrapper (background, overflow) |
.rk-reel-video-element | VideoSlide | The <video> element |
.rk-reel-video-poster | VideoSlide | Poster image (fades out on play) |
.rk-reel-video-poster.rk-visible | VideoSlide | State modifier applied to the poster while the video is paused/loading |
.rk-reel-nested-indicator | NestedSlider | Dot pagination under multi-media slides (position varies desktop vs. touch) |
.rk-reel-nested-nav | NestedSlider | Horizontal carousel arrows (hidden at 768px wide and narrower) |
.rk-reel-nested-nav-next | NestedSlider | Nested next arrow position |
.rk-reel-nested-nav-prev | NestedSlider | Nested prev arrow position |
.rk-reel-timeline | TimelineBar | Scrub-bar wrapper. Reuse on custom `renderTimeline` roots to inherit flush-bottom positioning, safe-area padding, and touch-device slide-overlay clearance. |
.rk-reel-timeline-track | TimelineBar | Track (unplayed region) |
.rk-reel-timeline-buffered | TimelineBar | Buffered segments layer |
.rk-reel-timeline-fill | TimelineBar | Played-progress fill |
.rk-reel-timeline-cursor | TimelineBar | Scrub-handle pill (floats above the track) |
Theming
Every color, size, z-index, and transition lives in a CSS custom property. Override one or many at :root (or any ancestor of the overlay) to retheme without touching component source.
| Token | Default | Controls |
|---|---|---|
--rk-reel-overlay-bg | #000 | Full-screen backdrop color |
--rk-reel-overlay-z | 1000 | Overlay z-index |
--rk-reel-button-bg | rgba(0, 0, 0, 0.5) | Default circular button background |
--rk-reel-button-bg-hover | rgba(255, 255, 255, 0.1) | Nav arrow background (and base hover state) |
--rk-reel-button-bg-hover-strong | rgba(255, 255, 255, 0.2) | Nav arrow hover background |
--rk-reel-button-fg | #fff | Button icon color |
--rk-reel-button-size | 44px | Button width / height |
--rk-reel-button-radius | 50% | Button border-radius |
--rk-reel-ui-z | 10 | Close / sound / nav z-index |
--rk-reel-edge-padding | 16px | Edge inset for close / sound / nav arrows |
--rk-reel-nav-gap | 8px | Spacing between stacked nav arrows |
--rk-reel-transition | 0.2s | Hover transition duration |
--rk-reel-loader-color | rgba(255, 255, 255, 0.12) | Wave loader gradient color |
--rk-reel-loader-duration | 1.8s | Wave loader animation duration |
--rk-reel-error-fg | rgba(255, 255, 255, 0.4) | Error icon and text color |
--rk-reel-error-text-size | 13px | Error message font size |
--rk-reel-slide-overlay-bg | linear-gradient(transparent, rgba(0, 0, 0, 0.7)) | Caption scrim gradient |
--rk-reel-slide-overlay-padding | 48px 16px 16px | Caption inner padding |
--rk-reel-slide-overlay-name-color | #fff | Author name color |
--rk-reel-slide-overlay-description-color | rgba(255, 255, 255, 0.9) | Description text color |
--rk-reel-slide-overlay-likes-color | rgba(255, 255, 255, 0.8) | Likes row text color |
--rk-reel-video-bg | #000 | Letterbox background behind <video> |
--rk-reel-nested-button-bg | rgba(0, 0, 0, 0.5) | Nested arrow background |
--rk-reel-nested-button-bg-hover | rgba(255, 255, 255, 0.2) | Nested arrow hover background |
--rk-reel-nested-button-size | 36px | Nested arrow size |
--rk-reel-nested-edge-padding | 12px | Nested arrow edge inset |
--rk-reel-timeline-track | rgba(255, 255, 255, 0.22) | Track background (unplayed region) |
--rk-reel-timeline-buffered | rgba(255, 255, 255, 0.4) | Buffered segments color |
--rk-reel-timeline-fill | #fff | Played-progress fill color |
--rk-reel-timeline-cursor | #fff | Scrub-handle pill color |
--rk-reel-timeline-height | 3px | Track height at rest |
--rk-reel-timeline-height-active | 6px | Track height on hover / focus / scrub |
--rk-reel-timeline-cursor-width | 10px | Scrub-pill width at rest |
--rk-reel-timeline-cursor-width-active | 14px | Scrub-pill width while scrubbing |
--rk-reel-timeline-cursor-height | 24px | Scrub-pill height at rest |
--rk-reel-timeline-cursor-height-active | 32px | Scrub-pill height while scrubbing |
--rk-reel-timeline-hitbox | 16px | Extra pointer hit-area above the track |
--rk-reel-timeline-transition | 0.15s ease-out | Track + pill grow/shrink animation |
--rk-reel-timeline-z | 11 | Timeline z-index (above the default UI layer) |
Drop the snippet below into a stylesheet loaded after @reelkit/react-reel-player/styles.css.
/* Brand the reel-player overlay */
:root {
--rk-reel-overlay-bg: #0f172a;
--rk-reel-button-bg: rgba(99, 102, 241, 0.65);
--rk-reel-button-bg-hover-strong: rgba(168, 85, 247, 0.85);
--rk-reel-edge-padding: 24px;
--rk-reel-button-size: 52px;
/* Timeline bar: brand-matched, beefier on desktop */
--rk-reel-timeline-track: rgba(99, 102, 241, 0.25);
--rk-reel-timeline-buffered: rgba(168, 85, 247, 0.45);
--rk-reel-timeline-fill: #a855f7;
--rk-reel-timeline-cursor: #a855f7;
--rk-reel-timeline-height: 4px;
--rk-reel-timeline-height-active: 8px;
--rk-reel-timeline-cursor-width-active: 18px;
--rk-reel-timeline-transition: 0.2s ease-out;
}Accessibility
The overlay root is a modal dialog (role="dialog", aria-modal="true"). Set ariaLabel to change the screen-reader announcement; it defaults to "Video player". Each slide carries role="group", aria-roledescription="slide", and an aria-label="Slide N of M", so swiping announces position in the sequence.
The overlay captures focus on open and returns it to the trigger on close. Tab and Shift+Tab cycle through focusable elements inside; focus that escapes (click outside, programmatic focus) gets pulled back. Implemented with captureFocusForReturn and createFocusTrap from @reelkit/core.
Keyboard Shortcuts
| Key | Action |
|---|---|
ArrowUp | Previous slide |
ArrowDown | Next slide |
ArrowLeft | Previous media (in nested slider) |
ArrowRight | Next media (in nested slider) |
Escape | Close player |