media
Media Chapter Scrubber
A semantic audio or video scrubber with proportional chapters, buffered progress, boundary snapping, and contextual seek feedback.
Installation
Install the dependencies, then copy the files for your preferred renderer.
pnpm add expo-haptics react-native-gesture-handler react-native-reanimatedanatomy.tsx<MediaChapterScrubber> <MediaChapterScrubber.Chapter /> <MediaChapterScrubber.Chapters /> <MediaChapterScrubber.Preview /> <MediaChapterScrubber.Thumb /> <MediaChapterScrubber.Track /></MediaChapterScrubber>Install tailwind variants
pnpm add tailwind-variantsUsage
MediaChapterScrubberExample.tsximport { Text } from "react-native";import { MediaChapterScrubber } from "@animations/ui/components/media/media-chapter-scrubber/uniwind";const chapters = [ { id: "intro", title: "Introduction", startTime: 0, endTime: 32 }, { id: "interview", title: "Interview", startTime: 32, endTime: 108 }, { id: "closing", title: "Closing", startTime: 108, endTime: 146 },] as const;function formatTime(seconds: number) { const minutes = Math.floor(seconds / 60); const remainder = Math.floor(seconds % 60); return `${minutes}:${remainder.toString().padStart(2, "0")}`;}export function MediaChapterScrubberExample() { return ( <MediaChapterScrubber bufferedTime={120} chapters={chapters} defaultCurrentTime={18} onSeekComplete={(time, chapter) => { console.log("Seek", time, chapter.id); }} > <MediaChapterScrubber.Preview> {({ activeChapter, currentTime, seeking }) => ( <> <Text className="font-bold">{activeChapter.title}</Text> <Text>{seeking ? "Release to seek" : formatTime(currentTime)}</Text> </> )} </MediaChapterScrubber.Preview> <MediaChapterScrubber.Track className="h-10"> <MediaChapterScrubber.Chapters className="h-2.5" /> <MediaChapterScrubber.Thumb className="size-4" /> </MediaChapterScrubber.Track> </MediaChapterScrubber> );}media-chapter-scrubber/config.tsexport const MEDIA_CHAPTER_SCRUBBER_CONFIG = { reportInterval: 0.1, snapThreshold: 14, spring: { damping: 24, mass: 0.75, stiffness: 280, },} as const;media-chapter-scrubber/types.tsimport type { ReactNode } from "react";import type { SharedValue } from "react-native-reanimated";export type MediaChapter = { endTime: number; id: string; startTime: number; title: string;};export type MediaChapterScrubberRenderState = { activeChapter: MediaChapter; currentTime: number; duration: number; progress: SharedValue<number>; seeking: boolean;};export type MediaChapterScrubberSlot = | ReactNode | (( state: MediaChapterScrubberRenderState, ) => ReactNode);export type MediaChapterScrubberBaseProps = { bufferedTime?: number; chapters: readonly [ MediaChapter, ...MediaChapter[], ]; children: ReactNode; currentTime?: number; defaultCurrentTime?: number; disabled?: boolean; haptics?: boolean; onCurrentTimeChange?: (time: number) => void; onSeekComplete?: ( time: number, chapter: MediaChapter, ) => void; onSeekingChange?: (seeking: boolean) => void; snapThreshold?: number; snapToChapters?: boolean;};media-chapter-scrubber/uniwind/index.tsximport { createContext, type ReactNode, useContext,} from "react";import { View, type AccessibilityActionEvent, type ViewProps,} from "react-native";import { GestureDetector } from "react-native-gesture-handler";import Animated, { clamp, useAnimatedStyle,} from "react-native-reanimated";import type { MediaChapterScrubberBaseProps, MediaChapterScrubberRenderState, MediaChapterScrubberSlot,} from "../types";import { useMediaChapterScrubber } from "../use-media-chapter-scrubber";import { mediaChapterScrubberVariants } from "./variants";type MediaChapterScrubberController = ReturnType< typeof useMediaChapterScrubber> & { chapters: MediaChapterScrubberBaseProps["chapters"]; disabled: boolean;};const MediaChapterScrubberContext = createContext<MediaChapterScrubberController | null>( null, );type MediaChapterScrubberPartProps = Omit< ViewProps, "children"> & { children?: MediaChapterScrubberSlot; className?: string;};type MediaChapterScrubberChapterProps = ViewProps & { className?: string; id: string;};type MediaChapterScrubberChaptersProps = Omit< ViewProps, "children"> & { children?: ( chapter: MediaChapterScrubberBaseProps["chapters"][number], index: number, ) => ReactNode; className?: string;};export type MediaChapterScrubberProps = MediaChapterScrubberBaseProps & { className?: string; };function useMediaChapterScrubberContext() { const context = useContext( MediaChapterScrubberContext, ); if (!context) { throw new Error( "MediaChapterScrubber compounds must be used inside MediaChapterScrubber.", ); } return context;}function renderSlot( children: MediaChapterScrubberSlot | undefined, state: MediaChapterScrubberRenderState,) { return typeof children === "function" ? children(state) : children;}/** Measured gesture surface that owns adjustable accessibility actions. */function MediaChapterScrubberTrack({ accessibilityLabel = "Media chapters", children, className, ...viewProps}: MediaChapterScrubberPartProps) { const controller = useMediaChapterScrubberContext(); const { track } = mediaChapterScrubberVariants(); function handleAccessibilityAction( event: AccessibilityActionEvent, ) { if (controller.disabled) { return; } if (event.nativeEvent.actionName === "increment") { controller.seekByChapter(1); } if (event.nativeEvent.actionName === "decrement") { controller.seekByChapter(-1); } } return ( <GestureDetector gesture={controller.gesture}> <View {...viewProps} accessibilityActions={[ { name: "increment" }, { name: "decrement" }, ]} accessibilityLabel={accessibilityLabel} accessibilityRole="adjustable" accessibilityState={{ disabled: controller.disabled, }} accessible className={track({ className })} onAccessibilityAction={handleAccessibilityAction} onLayout={controller.onTrackLayout} > {renderSlot(children, { activeChapter: controller.activeChapter, currentTime: controller.resolvedTime, duration: controller.duration, progress: controller.progress, seeking: controller.seeking, })} </View> </GestureDetector> );}/** One proportional buffered and played segment. Most consumers should use `Chapters`, which creates these segments from the root `chapters` model. */function MediaChapterScrubberChapter({ className, id, style, ...viewProps}: MediaChapterScrubberChapterProps) { const controller = useMediaChapterScrubberContext(); const chapter = controller.chapters.find( (candidate) => candidate.id === id, ); if (!chapter) { throw new Error(`Unknown media chapter: ${id}`); } const progress = controller.progress; const bufferedProgress = controller.bufferedProgress; const duration = controller.duration; const chapterDuration = chapter.endTime - chapter.startTime; const { chapter: chapterSlot, chapterBuffer, chapterProgress } = mediaChapterScrubberVariants(); const progressStyle = useAnimatedStyle(() => ({ width: `${clamp( (progress.get() * duration - chapter.startTime) / chapterDuration, 0, 1, ) * 100}%`, })); const bufferStyle = useAnimatedStyle(() => ({ width: `${clamp( (bufferedProgress.get() * duration - chapter.startTime) / chapterDuration, 0, 1, ) * 100}%`, })); return ( <View {...viewProps} className={chapterSlot({ className })} style={[{ flexGrow: chapterDuration }, style]} > <Animated.View className={chapterBuffer()} style={bufferStyle} /> <Animated.View className={chapterProgress()} style={progressStyle} /> </View> );}/** Renders every segment from the root `chapters` array, keeping that array as the single source of truth. */function MediaChapterScrubberChapters({ children, className, ...viewProps}: MediaChapterScrubberChaptersProps) { const controller = useMediaChapterScrubberContext(); return ( <> {controller.chapters.map((chapter, index) => children ? ( children(chapter, index) ) : ( <MediaChapterScrubberChapter {...viewProps} className={className} id={chapter.id} key={chapter.id} /> ), )} </> );}/** Measured draggable position indicator. */function MediaChapterScrubberThumb({ className, ...viewProps}: ViewProps & { className?: string }) { const controller = useMediaChapterScrubberContext(); const { thumb } = mediaChapterScrubberVariants(); return ( <Animated.View {...viewProps} className={thumb({ className })} onLayout={controller.onThumbLayout} style={controller.thumbAnimatedStyle} /> );}/** Consumer-owned chapter title, time label, thumbnail, or other seek feedback. */function MediaChapterScrubberPreview({ children, className, ...viewProps}: MediaChapterScrubberPartProps) { const controller = useMediaChapterScrubberContext(); const { preview } = mediaChapterScrubberVariants(); return ( <View {...viewProps} className={preview({ className })} > {renderSlot(children, { activeChapter: controller.activeChapter, currentTime: controller.resolvedTime, duration: controller.duration, progress: controller.progress, seeking: controller.seeking, })} </View> );}function MediaChapterScrubberRoot({ bufferedTime, chapters, children, className, currentTime, defaultCurrentTime, disabled = false, haptics, onCurrentTimeChange, onSeekComplete, onSeekingChange, snapThreshold, snapToChapters,}: MediaChapterScrubberProps) { const controller = useMediaChapterScrubber({ bufferedTime, chapters, currentTime, defaultCurrentTime, disabled, haptics, onCurrentTimeChange, onSeekComplete, onSeekingChange, snapThreshold, snapToChapters, }); const { root } = mediaChapterScrubberVariants({ disabled, }); return ( <MediaChapterScrubberContext.Provider value={{ ...controller, chapters, disabled }} > <View className={root({ className })}> {children} </View> </MediaChapterScrubberContext.Provider> );}export const MediaChapterScrubber = Object.assign( MediaChapterScrubberRoot, { Chapter: MediaChapterScrubberChapter, Chapters: MediaChapterScrubberChapters, Preview: MediaChapterScrubberPreview, Thumb: MediaChapterScrubberThumb, Track: MediaChapterScrubberTrack, },);media-chapter-scrubber/uniwind/variants.tsimport { tv } from "tailwind-variants";const mediaChapterScrubberSlots = { chapter: "h-2 flex-[1_1_0%] overflow-hidden rounded-md bg-[#d9d8d1]", chapterBuffer: "absolute inset-y-0 left-0 bg-[#b8b6ad]", chapterProgress: "absolute inset-y-0 left-0 bg-[#6f5cff]", preview: "flex-row items-center justify-between", root: "w-full gap-3.5", thumb: "absolute left-0 size-4 rounded-full border-2 border-white bg-[#171713]", track: "h-7 w-full flex-row items-center gap-1",} as const;export type MediaChapterScrubberSlotName = keyof typeof mediaChapterScrubberSlots;export const mediaChapterScrubberVariants = tv({ slots: mediaChapterScrubberSlots, variants: { disabled: { true: { root: "opacity-50", }, }, },});media-chapter-scrubber/use-media-chapter-scrubber.tsimport { useCallback, useMemo, useState,} from "react";import * as Haptics from "expo-haptics";import type { LayoutChangeEvent } from "react-native";import { Gesture } from "react-native-gesture-handler";import { clamp, runOnJS, useAnimatedReaction, useAnimatedStyle, useReducedMotion, useSharedValue, withSpring,} from "react-native-reanimated";import { MEDIA_CHAPTER_SCRUBBER_CONFIG } from "./config";import type { MediaChapter, MediaChapterScrubberBaseProps,} from "./types";type UseMediaChapterScrubberOptions = Omit< MediaChapterScrubberBaseProps, "children">;export function useMediaChapterScrubber({ bufferedTime = 0, chapters, currentTime, defaultCurrentTime = 0, disabled = false, haptics = true, onCurrentTimeChange, onSeekComplete, onSeekingChange, snapThreshold = MEDIA_CHAPTER_SCRUBBER_CONFIG.snapThreshold, snapToChapters = true,}: UseMediaChapterScrubberOptions) { const duration = chapters.at(-1)?.endTime ?? 0; const initialTime = clamp( currentTime ?? defaultCurrentTime, 0, duration, ); const [internalTime, setInternalTime] = useState(initialTime); const [seeking, setSeeking] = useState(false); const resolvedTime = currentTime ?? internalTime; const progress = useSharedValue( duration === 0 ? 0 : resolvedTime / duration, ); const bufferedProgress = useSharedValue( duration === 0 ? 0 : clamp(bufferedTime / duration, 0, 1), ); const trackWidth = useSharedValue(0); const thumbWidth = useSharedValue(0); const ownsProgress = useSharedValue(false); const lastReportedTime = useSharedValue(resolvedTime); const activeChapterIndex = useSharedValue( Math.max( chapters.findIndex( (chapter) => initialTime >= chapter.startTime && initialTime < chapter.endTime, ), 0, ), ); const reducedMotion = useReducedMotion(); const boundaries = useMemo( () => chapters.map((chapter) => chapter.startTime), [chapters], ); const activeChapter = chapters.find( (chapter) => resolvedTime >= chapter.startTime && resolvedTime < chapter.endTime, ) ?? chapters.at(-1) ?? chapters[0]; const reportTime = useCallback( (time: number) => { if (currentTime === undefined) { setInternalTime(time); } onCurrentTimeChange?.(time); }, [currentTime, onCurrentTimeChange], ); const beginSeeking = useCallback(() => { setSeeking(true); onSeekingChange?.(true); }, [onSeekingChange]); const crossChapter = useCallback( (index: number) => { if (haptics) { void Haptics.selectionAsync(); } }, [haptics], ); const finishSeeking = useCallback( (time: number, index: number) => { reportTime(time); setSeeking(false); onSeekingChange?.(false); onSeekComplete?.(time, chapters[index]); }, [ chapters, onSeekComplete, onSeekingChange, reportTime, ], ); const completeTapSeek = useCallback( (time: number, index: number) => { reportTime(time); onSeekComplete?.(time, chapters[index]); }, [chapters, onSeekComplete, reportTime], ); const seekByChapter = useCallback( (direction: -1 | 1) => { if (disabled) { return; } const currentIndex = chapters.findIndex( (chapter) => chapter.id === activeChapter.id, ); const nextIndex = clamp( currentIndex + direction, 0, chapters.length - 1, ); const nextTime = chapters[nextIndex].startTime; const nextProgress = duration === 0 ? 0 : nextTime / duration; ownsProgress.set(true); if (reducedMotion) { progress.set(nextProgress); ownsProgress.set(false); } else { progress.set( withSpring( nextProgress, MEDIA_CHAPTER_SCRUBBER_CONFIG.spring, (finished) => { if (finished) { ownsProgress.set(false); } }, ), ); } reportTime(nextTime); onSeekComplete?.(nextTime, chapters[nextIndex]); if (haptics && nextIndex !== currentIndex) { void Haptics.selectionAsync(); } }, [ activeChapter.id, chapters, disabled, duration, haptics, onSeekComplete, ownsProgress, progress, reducedMotion, reportTime, ], ); useAnimatedReaction( () => duration === 0 ? 0 : clamp(resolvedTime / duration, 0, 1), (nextProgress) => { if (ownsProgress.get()) { return; } progress.set(nextProgress); }, ); useAnimatedReaction( () => duration === 0 ? 0 : clamp(bufferedTime / duration, 0, 1), (nextProgress) => { bufferedProgress.set(nextProgress); }, ); const panGesture = useMemo( () => Gesture.Pan() .enabled(!disabled && duration > 0) .onStart((event) => { const width = trackWidth.get(); const availableTravel = width - thumbWidth.get(); if (availableTravel <= 0) { return; } runOnJS(beginSeeking)(); ownsProgress.set(true); progress.set( clamp( (event.x - thumbWidth.get() / 2) / availableTravel, 0, 1, ), ); }) .onUpdate((event) => { const width = trackWidth.get(); const availableTravel = width - thumbWidth.get(); if (availableTravel <= 0) { return; } const nextProgress = clamp( (event.x - thumbWidth.get() / 2) / availableTravel, 0, 1, ); const nextTime = nextProgress * duration; let nextChapterIndex = chapters.length - 1; for ( let index = 0; index < chapters.length; index += 1 ) { if (nextTime < chapters[index].endTime) { nextChapterIndex = index; break; } } progress.set(nextProgress); if ( nextChapterIndex !== activeChapterIndex.get() ) { activeChapterIndex.set(nextChapterIndex); runOnJS(crossChapter)(nextChapterIndex); } if ( Math.abs( nextTime - lastReportedTime.get(), ) >= MEDIA_CHAPTER_SCRUBBER_CONFIG.reportInterval ) { lastReportedTime.set(nextTime); runOnJS(reportTime)(nextTime); } }) .onEnd(() => { const width = trackWidth.get(); const availableTravel = width - thumbWidth.get(); let targetTime = progress.get() * duration; if ( snapToChapters && availableTravel > 0 ) { const timeThreshold = (snapThreshold / availableTravel) * duration; let closestBoundary = targetTime; let closestDistance = timeThreshold; for ( let index = 0; index < boundaries.length; index += 1 ) { const distance = Math.abs( boundaries[index] - targetTime, ); if (distance <= closestDistance) { closestBoundary = boundaries[index]; closestDistance = distance; } } targetTime = closestBoundary; } const targetProgress = duration === 0 ? 0 : targetTime / duration; let targetChapterIndex = chapters.length - 1; for ( let index = 0; index < chapters.length; index += 1 ) { if (targetTime < chapters[index].endTime) { targetChapterIndex = index; break; } } if ( targetChapterIndex !== activeChapterIndex.get() ) { activeChapterIndex.set(targetChapterIndex); runOnJS(crossChapter)(targetChapterIndex); } if (reducedMotion) { progress.set(targetProgress); ownsProgress.set(false); } else { progress.set( withSpring( targetProgress, MEDIA_CHAPTER_SCRUBBER_CONFIG.spring, (finished) => { if (finished) { ownsProgress.set(false); } }, ), ); } runOnJS(finishSeeking)( targetTime, targetChapterIndex, ); }), [ activeChapterIndex, beginSeeking, boundaries, chapters, crossChapter, disabled, duration, finishSeeking, lastReportedTime, ownsProgress, progress, reducedMotion, reportTime, snapThreshold, snapToChapters, thumbWidth, trackWidth, ], ); const tapGesture = useMemo( () => Gesture.Tap() .enabled(!disabled && duration > 0) .onEnd((event, finished) => { if (!finished) { return; } const width = trackWidth.get(); const availableTravel = width - thumbWidth.get(); if (availableTravel <= 0) { return; } const targetProgress = clamp( (event.x - thumbWidth.get() / 2) / availableTravel, 0, 1, ); const targetTime = targetProgress * duration; let targetChapterIndex = chapters.length - 1; for ( let index = 0; index < chapters.length; index += 1 ) { if (targetTime < chapters[index].endTime) { targetChapterIndex = index; break; } } if ( targetChapterIndex !== activeChapterIndex.get() ) { activeChapterIndex.set(targetChapterIndex); runOnJS(crossChapter)(targetChapterIndex); } ownsProgress.set(true); if (reducedMotion) { progress.set(targetProgress); ownsProgress.set(false); } else { progress.set( withSpring( targetProgress, MEDIA_CHAPTER_SCRUBBER_CONFIG.spring, (springFinished) => { if (springFinished) { ownsProgress.set(false); } }, ), ); } runOnJS(completeTapSeek)( targetTime, targetChapterIndex, ); }), [ activeChapterIndex, chapters, completeTapSeek, crossChapter, disabled, duration, ownsProgress, progress, reducedMotion, thumbWidth, trackWidth, ], ); const gesture = useMemo( () => Gesture.Race(panGesture, tapGesture), [panGesture, tapGesture], ); const onTrackLayout = useCallback( (event: LayoutChangeEvent) => { trackWidth.set(event.nativeEvent.layout.width); }, [trackWidth], ); const onThumbLayout = useCallback( (event: LayoutChangeEvent) => { thumbWidth.set(event.nativeEvent.layout.width); }, [thumbWidth], ); const thumbAnimatedStyle = useAnimatedStyle(() => ({ transform: [ { translateX: progress.get() * Math.max( trackWidth.get() - thumbWidth.get(), 0, ), }, ], })); return { activeChapter, bufferedProgress, duration, gesture, onThumbLayout, onTrackLayout, progress, resolvedTime, seekByChapter, seeking, thumbAnimatedStyle, };}MediaChapterScrubber
MediaChapterScrubber.Chapter
One proportional buffered and played segment. Most consumers should use `Chapters`, which creates these segments from the root `chapters` model.
MediaChapterScrubber.Chapter extends all props from ViewProps, with the additional component-specific props shown below.
MediaChapterScrubber.Chapters
Renders every segment from the root `chapters` array, keeping that array as the single source of truth.
MediaChapterScrubber.Preview
Consumer-owned chapter title, time label, thumbnail, or other seek feedback.
MediaChapterScrubber.Thumb
Measured draggable position indicator.
MediaChapterScrubber.Thumb extends all props from ViewProps, with the additional component-specific props shown below.
MediaChapterScrubber.Track
Measured gesture surface that owns adjustable accessibility actions.
