Reel
A full-height, story-style video/image viewer with segmented progress bars. Purely presentational — no Morphos equivalent.
Installation
npx kosmesis add reelpnpm dlx kosmesis add reelyarn dlx kosmesis add reelbunx kosmesis add reelInstall the following dependencies:
npm install @morphos/iconspnpm add @morphos/iconsyarn add @morphos/iconsbun add @morphos/iconsCopy and paste the following code into your project.
import { StatefulComponent, StatelessComponent } from "@praxisjs/core";import { Component, FunctionProp, Prop, Ref, State, type Ref as RefType } from "@praxisjs/decorators";import type { Children } from "@praxisjs/shared";import { Icon } from "@morphos/icons";import { cn } from "@/lib/utils";export interface ReelItem { id: string; type: "video" | "image"; src: string; duration: number; alt?: string; title?: string; description?: string;}export interface ReelStateProps { items: ReelItem[]; defaultIndex?: number; index?: number; onIndexChange?: (index: number) => void; defaultPlaying?: boolean; playing?: boolean; onPlayingChange?: (playing: boolean) => void; defaultMuted?: boolean; muted?: boolean; onMutedChange?: (muted: boolean) => void;}/** * Progress advances via an rAF poll, not `timeupdate` (images have none). Video playback syncs * imperatively via a DOM query rather than a reactive binding, so toggling play/mute never recreates the `<video>`. */@Component()export class ReelState extends StatefulComponent { @Prop() items!: ReelItem[]; @Prop() defaultIndex = 0; @Prop() index?: number; @FunctionProp() onIndexChange?: ReelStateProps["onIndexChange"]; @Prop() defaultPlaying = true; @Prop() playing?: boolean; @FunctionProp() onPlayingChange?: ReelStateProps["onPlayingChange"]; @Prop() defaultMuted = true; @Prop() muted?: boolean; @FunctionProp() onMutedChange?: ReelStateProps["onMutedChange"]; @State() private _index = 0; @State() private _playing = true; @State() private _muted = true; @State() progress = 0; @Ref<HTMLDivElement>() rootRef!: RefType<HTMLDivElement>; private _rafId?: number; private _segmentStartMs = 0; private _pausedElapsedMs = 0; private _lastIndex = -1; private _lastPlaying = false; private _lastMuted = false; private _advancedPending = false; onBeforeMount(): void { this._index = this.defaultIndex; this._playing = this.defaultPlaying; this._muted = this.defaultMuted; } onMount(): void { this._segmentStartMs = performance.now(); this._rafId = requestAnimationFrame(this._tick); } onUnmount(): void { if (this._rafId !== undefined) cancelAnimationFrame(this._rafId); } get currentIndex(): number { return this.index ?? this._index; } get isPlaying(): boolean { return this.playing ?? this._playing; } get isMuted(): boolean { return this.muted ?? this._muted; } get currentItem(): ReelItem | undefined { return this.items[this.currentIndex]; } setIndex(index: number): void { const total = this.items.length; if (total === 0) return; const clamped = ((index % total) + total) % total; if (this.index === undefined) this._index = clamped; this.onIndexChange?.(clamped); } next(): void { this.setIndex(this.currentIndex + 1); } prev(): void { this.setIndex(this.currentIndex - 1); } setPlaying(playing: boolean): void { if (this.playing === undefined) this._playing = playing; this.onPlayingChange?.(playing); } togglePlaying(): void { this.setPlaying(!this.isPlaying); } setMuted(muted: boolean): void { if (this.muted === undefined) this._muted = muted; this.onMutedChange?.(muted); } toggleMuted(): void { this.setMuted(!this.isMuted); } syncPlayback(): void { const video = this.rootRef.current?.querySelector<HTMLVideoElement>("video"); if (!video) return; video.muted = this.isMuted; if (this.isPlaying) video.play().catch(() => { /* ignore autoplay rejection */ }); else video.pause(); } private readonly _tick = () => { const idx = this.currentIndex; const playing = this.isPlaying; const muted = this.isMuted; const now = performance.now(); let shouldSync = false; if (idx !== this._lastIndex) { this._lastIndex = idx; this._segmentStartMs = now; this._pausedElapsedMs = 0; this._advancedPending = false; this.progress = 0; shouldSync = true; } if (playing !== this._lastPlaying) { if (playing) this._segmentStartMs = now; else this._pausedElapsedMs += now - this._segmentStartMs; this._lastPlaying = playing; shouldSync = true; } if (muted !== this._lastMuted) { this._lastMuted = muted; shouldSync = true; } if (shouldSync) this.syncPlayback(); const durationMs = (this.currentItem?.duration ?? 5) * 1000; if (playing && durationMs > 0) { const elapsed = this._pausedElapsedMs + (now - this._segmentStartMs); const pct = Math.min((elapsed / durationMs) * 100, 100); this.progress = pct; if (pct >= 100 && !this._advancedPending) { this._advancedPending = true; this.next(); } } this._rafId = requestAnimationFrame(this._tick); }; render() { return null; }}export interface ReelProps { state: ReelState; class?: string; id?: string; children?: Children;}@Component()export class Reel extends StatelessComponent<ReelProps> { render() { const { state, class: cls, id, children } = this.props; return ( <div ref={state.rootRef} id={id} data-slot="reel" class={cn("relative isolate aspect-[9/16] h-full w-auto overflow-hidden bg-black", cls)}> {children} </div> ); }}export interface ReelMediaProps { state: ReelState; class?: string;}/** Reads only `currentIndex`/`currentItem`, never `isMuted`/`isPlaying` — those are handled by `ReelState.syncPlayback` so muting/pausing doesn't recreate the media element. */@Component()export class ReelMedia extends StatelessComponent<ReelMediaProps> { render() { const { state, class: cls } = this.props; return ( <div data-slot="reel-media" class={cn("relative size-full", cls)}> {() => { const item = state.currentItem; if (!item) return null; const mediaClass = "absolute inset-0 size-full animate-in fade-in object-cover duration-300"; return item.type === "video" ? ( <video key={item.id} src={item.src} loop playsInline muted={state.defaultMuted} class={mediaClass} /> ) : ( <img key={item.id} src={item.src} alt={item.alt ?? ""} class={mediaClass} /> ); }} </div> ); }}export interface ReelProgressProps { state: ReelState; class?: string;}@Component()export class ReelProgress extends StatelessComponent<ReelProgressProps> { render() { const { state, class: cls } = this.props; return ( <div data-slot="reel-progress" class={cn("absolute inset-x-0 top-0 z-20 flex gap-1 p-2", cls)}> {() => state.items.map((item, index) => { const pct = index < state.currentIndex ? 100 : index === state.currentIndex ? state.progress : 0; return ( <div key={item.id} class="h-0.5 flex-1 overflow-hidden rounded-full bg-white/30"> <div class="h-full bg-white" style={{ width: `${String(pct)}%` }} /> </div> ); }) } </div> ); }}export interface ReelSlotProps { class?: string; children?: Children;}@Component()export class ReelHeader extends StatelessComponent<ReelSlotProps> { render() { const { class: cls, children } = this.props; return ( <div data-slot="reel-header" class={cn("absolute inset-x-0 top-0 z-20 bg-gradient-to-b from-black/60 to-transparent p-4 pt-6", cls)}> {children} </div> ); }}@Component()export class ReelFooter extends StatelessComponent<ReelSlotProps> { render() { const { class: cls, children } = this.props; return ( <div data-slot="reel-footer" class={cn("absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/60 to-transparent p-4", cls)}> {children} </div> ); }}@Component()export class ReelControls extends StatelessComponent<ReelSlotProps> { render() { const { class: cls, children } = this.props; return ( <div data-slot="reel-controls" class={cn("absolute inset-x-0 bottom-0 z-20 flex items-center justify-between bg-gradient-to-t from-black/60 to-transparent p-4", cls)} > {children} </div> ); }}export interface ReelNavigationProps { state: ReelState; class?: string;}@Component()export class ReelNavigation extends StatelessComponent<ReelNavigationProps> { render() { const { state, class: cls } = this.props; return ( <button type="button" aria-label="Navigate reel" class={cn("absolute inset-0 z-10 flex", cls)} onClick={(event: MouseEvent) => { const rect = (event.currentTarget as HTMLElement).getBoundingClientRect(); if (event.clientX - rect.left < rect.width / 2) state.prev(); else state.next(); }} > <span class="flex-1 cursor-pointer" /> <span class="flex-1 cursor-pointer" /> </button> ); }}export interface ReelButtonProps { state: ReelState; class?: string; children?: Children;}const REEL_BUTTON_CLASS = "rounded-full p-2 text-white hover:bg-white/10";@Component()export class ReelPreviousButton extends StatelessComponent<ReelButtonProps> { render() { const { state, class: cls, children } = this.props; return ( <button type="button" aria-label="Previous" class={cn(REEL_BUTTON_CLASS, cls)} onClick={() => { state.prev(); }}> {children ?? <Icon name="ChevronLeft" size={16} />} </button> ); }}@Component()export class ReelNextButton extends StatelessComponent<ReelButtonProps> { render() { const { state, class: cls, children } = this.props; return ( <button type="button" aria-label="Next" class={cn(REEL_BUTTON_CLASS, cls)} onClick={() => { state.next(); }}> {children ?? <Icon name="ChevronRight" size={16} />} </button> ); }}@Component()export class ReelPlayButton extends StatelessComponent<ReelButtonProps> { render() { const { state, class: cls } = this.props; return ( <button type="button" aria-label={() => (state.isPlaying ? "Pause" : "Play")} class={cn(REEL_BUTTON_CLASS, cls)} onClick={() => { state.togglePlaying(); }} > {() => (state.isPlaying ? <Icon name="Pause" size={16} /> : <Icon name="Play" size={16} />)} </button> ); }}@Component()export class ReelMuteButton extends StatelessComponent<ReelButtonProps> { render() { const { state, class: cls } = this.props; return ( <button type="button" aria-label={() => (state.isMuted ? "Unmute" : "Mute")} class={cn(REEL_BUTTON_CLASS, cls)} onClick={() => { state.toggleMuted(); }} > {() => (state.isMuted ? <Icon name="VolumeX" size={16} /> : <Icon name="Volume2" size={16} />)} </button> ); }}Install the following dependencies:
npm install @praxisjs/css @morphos/iconspnpm add @praxisjs/css @morphos/iconsyarn add @praxisjs/css @morphos/iconsbun add @praxisjs/css @morphos/iconsCopy and paste the following code into your project.
import { StatefulComponent, StatelessComponent } from "@praxisjs/core";import { cx, Stylesheet, Styled } from "@praxisjs/css";import { Component, FunctionProp, Prop, Ref, State, type Ref as RefType } from "@praxisjs/decorators";import type { Children } from "@praxisjs/shared";import { Icon } from "@morphos/icons";export interface ReelItem { id: string; type: "video" | "image"; src: string; duration: number; alt?: string; title?: string; description?: string;}export interface ReelStateProps { items: ReelItem[]; defaultIndex?: number; index?: number; onIndexChange?: (index: number) => void; defaultPlaying?: boolean; playing?: boolean; onPlayingChange?: (playing: boolean) => void; defaultMuted?: boolean; muted?: boolean; onMutedChange?: (muted: boolean) => void;}/** * Progress advances via an rAF poll, not `timeupdate` (images have none). Video playback syncs * imperatively via a DOM query rather than a reactive binding, so toggling play/mute never recreates the `<video>`. */@Component()export class ReelState extends StatefulComponent { @Prop() items!: ReelItem[]; @Prop() defaultIndex = 0; @Prop() index?: number; @FunctionProp() onIndexChange?: ReelStateProps["onIndexChange"]; @Prop() defaultPlaying = true; @Prop() playing?: boolean; @FunctionProp() onPlayingChange?: ReelStateProps["onPlayingChange"]; @Prop() defaultMuted = true; @Prop() muted?: boolean; @FunctionProp() onMutedChange?: ReelStateProps["onMutedChange"]; @State() private _index = 0; @State() private _playing = true; @State() private _muted = true; @State() progress = 0; @Ref<HTMLDivElement>() rootRef!: RefType<HTMLDivElement>; private _rafId?: number; private _segmentStartMs = 0; private _pausedElapsedMs = 0; private _lastIndex = -1; private _lastPlaying = false; private _lastMuted = false; private _advancedPending = false; onBeforeMount(): void { this._index = this.defaultIndex; this._playing = this.defaultPlaying; this._muted = this.defaultMuted; } onMount(): void { this._segmentStartMs = performance.now(); this._rafId = requestAnimationFrame(this._tick); } onUnmount(): void { if (this._rafId !== undefined) cancelAnimationFrame(this._rafId); } get currentIndex(): number { return this.index ?? this._index; } get isPlaying(): boolean { return this.playing ?? this._playing; } get isMuted(): boolean { return this.muted ?? this._muted; } get currentItem(): ReelItem | undefined { return this.items[this.currentIndex]; } setIndex(index: number): void { const total = this.items.length; if (total === 0) return; const clamped = ((index % total) + total) % total; if (this.index === undefined) this._index = clamped; this.onIndexChange?.(clamped); } next(): void { this.setIndex(this.currentIndex + 1); } prev(): void { this.setIndex(this.currentIndex - 1); } setPlaying(playing: boolean): void { if (this.playing === undefined) this._playing = playing; this.onPlayingChange?.(playing); } togglePlaying(): void { this.setPlaying(!this.isPlaying); } setMuted(muted: boolean): void { if (this.muted === undefined) this._muted = muted; this.onMutedChange?.(muted); } toggleMuted(): void { this.setMuted(!this.isMuted); } syncPlayback(): void { const video = this.rootRef.current?.querySelector<HTMLVideoElement>("video"); if (!video) return; video.muted = this.isMuted; if (this.isPlaying) video.play().catch(() => { /* ignore autoplay rejection */ }); else video.pause(); } private readonly _tick = () => { const idx = this.currentIndex; const playing = this.isPlaying; const muted = this.isMuted; const now = performance.now(); let shouldSync = false; if (idx !== this._lastIndex) { this._lastIndex = idx; this._segmentStartMs = now; this._pausedElapsedMs = 0; this._advancedPending = false; this.progress = 0; shouldSync = true; } if (playing !== this._lastPlaying) { if (playing) this._segmentStartMs = now; else this._pausedElapsedMs += now - this._segmentStartMs; this._lastPlaying = playing; shouldSync = true; } if (muted !== this._lastMuted) { this._lastMuted = muted; shouldSync = true; } if (shouldSync) this.syncPlayback(); const durationMs = (this.currentItem?.duration ?? 5) * 1000; if (playing && durationMs > 0) { const elapsed = this._pausedElapsedMs + (now - this._segmentStartMs); const pct = Math.min((elapsed / durationMs) * 100, 100); this.progress = pct; if (pct >= 100 && !this._advancedPending) { this._advancedPending = true; this.next(); } } this._rafId = requestAnimationFrame(this._tick); }; render() { return null; }}class ReelStyles extends Stylesheet { $root = this.css({ position: "relative", isolation: "isolate", aspectRatio: "9 / 16", height: "100%", width: "auto", overflow: "hidden", backgroundColor: "black", }); $media = this.css({ position: "relative", height: "100%", width: "100%" }); $mediaItem = this.css({ position: "absolute", inset: "0", height: "100%", width: "100%", objectFit: "cover", }); $progress = this.css({ position: "absolute", insetInline: "0", top: "0", zIndex: 20, display: "flex", gap: "0.25rem", padding: "0.5rem" }); $progressTrack = this.css({ height: "0.125rem", flex: "1", overflow: "hidden", borderRadius: "9999px", backgroundColor: "rgb(255 255 255 / 0.3)" }); $progressFill = this.css({ height: "100%", backgroundColor: "white" }); $header = this.css({ position: "absolute", insetInline: "0", top: "0", zIndex: 20, backgroundImage: "linear-gradient(to bottom, rgb(0 0 0 / 0.6), transparent)", padding: "1rem", paddingTop: "1.5rem", }); $footer = this.css({ position: "absolute", insetInline: "0", bottom: "0", zIndex: 20, backgroundImage: "linear-gradient(to top, rgb(0 0 0 / 0.6), transparent)", padding: "1rem", }); $controls = this.css({ position: "absolute", insetInline: "0", bottom: "0", zIndex: 20, display: "flex", alignItems: "center", justifyContent: "space-between", backgroundImage: "linear-gradient(to top, rgb(0 0 0 / 0.6), transparent)", padding: "1rem", }); $navigation = this.css({ position: "absolute", inset: "0", zIndex: 10, display: "flex", border: "none", background: "none", padding: "0" }); $navigationZone = this.css({ flex: "1", cursor: "pointer" }); $button = this.css({ borderRadius: "9999px", padding: "0.5rem", color: "white", border: "none", background: "none", cursor: "pointer", }).on("&:hover", { backgroundColor: "rgb(255 255 255 / 0.1)" });}export interface ReelProps { state: ReelState; class?: string; id?: string; children?: Children;}@Component()export class Reel extends StatelessComponent<ReelProps> { @Styled(ReelStyles) $s!: ReelStyles; render() { const { state, class: cls, id, children } = this.props; return ( <div ref={state.rootRef} id={id} data-slot="reel" class={cx(this.$s.$root, cls)}> {children} </div> ); }}export interface ReelMediaProps { state: ReelState; class?: string;}/** Reads only `currentIndex`/`currentItem`, never `isMuted`/`isPlaying` — those are handled by `ReelState.syncPlayback` so muting/pausing doesn't recreate the media element. */@Component()export class ReelMedia extends StatelessComponent<ReelMediaProps> { @Styled(ReelStyles) $s!: ReelStyles; render() { const { state, class: cls } = this.props; return ( <div data-slot="reel-media" class={cx(this.$s.$media, cls)}> {() => { const item = state.currentItem; if (!item) return null; return item.type === "video" ? ( <video key={item.id} src={item.src} loop playsInline muted={state.defaultMuted} class={this.$s.$mediaItem} /> ) : ( <img key={item.id} src={item.src} alt={item.alt ?? ""} class={this.$s.$mediaItem} /> ); }} </div> ); }}export interface ReelProgressProps { state: ReelState; class?: string;}@Component()export class ReelProgress extends StatelessComponent<ReelProgressProps> { @Styled(ReelStyles) $s!: ReelStyles; render() { const { state, class: cls } = this.props; return ( <div data-slot="reel-progress" class={cx(this.$s.$progress, cls)}> {() => state.items.map((item, index) => { const pct = index < state.currentIndex ? 100 : index === state.currentIndex ? state.progress : 0; return ( <div key={item.id} class={this.$s.$progressTrack}> <div class={this.$s.$progressFill} style={{ width: `${String(pct)}%` }} /> </div> ); }) } </div> ); }}export interface ReelSlotProps { class?: string; children?: Children;}@Component()export class ReelHeader extends StatelessComponent<ReelSlotProps> { @Styled(ReelStyles) $s!: ReelStyles; render() { const { class: cls, children } = this.props; return ( <div data-slot="reel-header" class={cx(this.$s.$header, cls)}> {children} </div> ); }}@Component()export class ReelFooter extends StatelessComponent<ReelSlotProps> { @Styled(ReelStyles) $s!: ReelStyles; render() { const { class: cls, children } = this.props; return ( <div data-slot="reel-footer" class={cx(this.$s.$footer, cls)}> {children} </div> ); }}@Component()export class ReelControls extends StatelessComponent<ReelSlotProps> { @Styled(ReelStyles) $s!: ReelStyles; render() { const { class: cls, children } = this.props; return ( <div data-slot="reel-controls" class={cx(this.$s.$controls, cls)}> {children} </div> ); }}export interface ReelNavigationProps { state: ReelState; class?: string;}@Component()export class ReelNavigation extends StatelessComponent<ReelNavigationProps> { @Styled(ReelStyles) $s!: ReelStyles; render() { const { state, class: cls } = this.props; return ( <button type="button" aria-label="Navigate reel" class={cx(this.$s.$navigation, cls)} onClick={(event: MouseEvent) => { const rect = (event.currentTarget as HTMLElement).getBoundingClientRect(); if (event.clientX - rect.left < rect.width / 2) state.prev(); else state.next(); }} > <span class={this.$s.$navigationZone} /> <span class={this.$s.$navigationZone} /> </button> ); }}export interface ReelButtonProps { state: ReelState; class?: string; children?: Children;}@Component()export class ReelPreviousButton extends StatelessComponent<ReelButtonProps> { @Styled(ReelStyles) $s!: ReelStyles; render() { const { state, class: cls, children } = this.props; return ( <button type="button" aria-label="Previous" class={cx(this.$s.$button, cls)} onClick={() => { state.prev(); }}> {children ?? <Icon name="ChevronLeft" size={16} />} </button> ); }}@Component()export class ReelNextButton extends StatelessComponent<ReelButtonProps> { @Styled(ReelStyles) $s!: ReelStyles; render() { const { state, class: cls, children } = this.props; return ( <button type="button" aria-label="Next" class={cx(this.$s.$button, cls)} onClick={() => { state.next(); }}> {children ?? <Icon name="ChevronRight" size={16} />} </button> ); }}@Component()export class ReelPlayButton extends StatelessComponent<ReelButtonProps> { @Styled(ReelStyles) $s!: ReelStyles; render() { const { state, class: cls } = this.props; return ( <button type="button" aria-label={() => (state.isPlaying ? "Pause" : "Play")} class={cx(this.$s.$button, cls)} onClick={() => { state.togglePlaying(); }} > {() => (state.isPlaying ? <Icon name="Pause" size={16} /> : <Icon name="Play" size={16} />)} </button> ); }}@Component()export class ReelMuteButton extends StatelessComponent<ReelButtonProps> { @Styled(ReelStyles) $s!: ReelStyles; render() { const { state, class: cls } = this.props; return ( <button type="button" aria-label={() => (state.isMuted ? "Unmute" : "Mute")} class={cx(this.$s.$button, cls)} onClick={() => { state.toggleMuted(); }} > {() => (state.isMuted ? <Icon name="VolumeX" size={16} /> : <Icon name="Volume2" size={16} />)} </button> ); }}Examples
About
An Instagram/Snapchat-Stories-style media viewer — purely presentational, no Morphos equivalent.
ReelState owns the current index, play/pause, mute, and a per-item progress timer, instantiated
directly and passed to Reel and every part via a state prop, the same convention as
CarouselState. Every value works controlled or uncontrolled — pass index/playing/
muted to drive it externally, or leave them out and read onIndexChange/onPlayingChange/
onMutedChange instead.
Progress isn't driven by the video's own timeupdate (an image has no such event) — a single
requestAnimationFrame loop advances each item's progress bar against its own duration (in
seconds) and auto-advances to the next item once it elapses, looping back to the first item at the
end. Tapping the left half of ReelNavigation goes to the previous item, the right half to the
next — same as tapping through a real Stories viewer.
Usage
import {
Reel,
ReelState,
ReelControls,
ReelHeader,
ReelMedia,
ReelMuteButton,
ReelNavigation,
ReelPlayButton,
ReelProgress,
type ReelItem,
} from "@/components/ui/reel";
const items: ReelItem[] = [
{ id: "1", type: "video", src: "/videos/one.mp4", duration: 10, title: "First" },
{ id: "2", type: "image", src: "/images/two.jpg", duration: 5, alt: "Second", title: "Second" },
];
const state = new ReelState({ items });
<Reel state={state}>
<ReelProgress state={state} />
<ReelHeader>
<p class="font-semibold text-white">{items[state.currentIndex].title}</p>
</ReelHeader>
<ReelMedia state={state} />
<ReelNavigation state={state} />
<ReelControls>
<ReelPlayButton state={state} />
<ReelMuteButton state={state} />
</ReelControls>
</Reel>Props
ReelState (constructor props)
| Prop | Type | Default |
|---|---|---|
items | ReelItem[] | — |
defaultIndex | number | 0 |
index | number | — (uncontrolled when omitted) |
onIndexChange | (index: number) => void | — |
defaultPlaying | boolean | true |
playing | boolean | — (uncontrolled when omitted) |
onPlayingChange | (playing: boolean) => void | — |
defaultMuted | boolean | true |
muted | boolean | — (uncontrolled when omitted) |
onMutedChange | (muted: boolean) => void | — |
ReelItem is { id: string; type: "video" | "image"; src: string; duration: number; alt?: string; title?: string; description?: string }.
duration is in seconds and drives the progress bar / auto-advance regardless of the item's type.
Reel / ReelMedia / ReelProgress / ReelNavigation / ReelPreviousButton / ReelNextButton / ReelPlayButton / ReelMuteButton
| Prop | Type | Default |
|---|---|---|
state | ReelState | — |
class | string | — |
ReelHeader / ReelFooter / ReelControls
| Prop | Type | Default |
|---|---|---|
class | string | — |