Custom Components

Replace the sheet, sliders, buffering spinner and button feedback the player renders with your own components or libraries.

The player is built from a few building blocks you don't place yourself: the sheet that hosts the settings menu, the sliders in the progress bar and volume control, the buffering spinner and the press feedback on every button. Pass your own through the components prop of VideoProvider to use any library you like, such as @gorhom/bottom-sheet for the menu or the platform's native slider.

import { VideoProvider, VideoPlayer, DefaultLayout } from 'react-native-video-toolkit';
import { GorhomSheet } from './GorhomSheet';
import { NativeSlider } from './NativeSlider';

export const Player = () => (
  <VideoProvider components={{ Sheet: GorhomSheet, Slider: NativeSlider }}>
    <VideoPlayer source={{ uri: 'https://example.com/video.mp4' }}>
      <DefaultLayout />
    </VideoPlayer>
  </VideoProvider>
);

Anything you leave out keeps the toolkit's default, so you can replace one piece at a time.

KeyUsed byDefaultProps
SheetMenu.Content (the settings menu)Bottom sheet; a right-hand side panel on TVSheetProps
SliderProgressBar, VolumeControlreact-native-awesome-sliderSliderProps
SpinnerLoadingSpinnerActivityIndicatorSpinnerProps
PressFeedbackEvery control button (BaseButton)Material ripple on native; hover and press tint on webPressFeedbackProps

Icons have their own props on each control (for example renderSettingIcon on the settings button), so they aren't part of components.

Define your components at module level, not inside another component's render. A component created during render is a new type every time, so the player would remount it on every update.

A complete example using @gorhom/bottom-sheet, @expo/ui, @react-native-community/slider and Reanimated lives in the repository's example app: example/app/(tabs)/components.tsx, with the components in example/components/.

Sheet

The sheet hosts the settings menu. The toolkit owns the open state, so your sheet only has to follow it:

  • Show the sheet while open is true, and hide it when it turns false.
  • When the user dismisses the sheet (swipe down, backdrop tap, back button), call onOpenChange(false).
  • Render children inside it. They already have access to the player state, so the sheet can render through its own portal, even one placed outside VideoProvider.
  • style carries the menu background color and padding from the theme and Menu.Content's sheetStyle.

@gorhom/bottom-sheet

BottomSheetModal is imperative, so mirror open onto present() and dismiss(). Place BottomSheetModalProvider at your app root, above your navigator, so the sheet is drawn over tab bars and headers. The menu keeps working even though the sheet is then rendered outside VideoProvider.

import { useCallback, useEffect, useRef } from 'react';
import { StyleSheet } from 'react-native';
import {
  BottomSheetBackdrop,
  BottomSheetModal,
  BottomSheetView,
  type BottomSheetBackdropProps,
} from '@gorhom/bottom-sheet';
import type { SheetProps } from 'react-native-video-toolkit';

export function GorhomSheet({ open, onOpenChange, children, style }: SheetProps) {
  const ref = useRef<BottomSheetModal>(null);
  const presented = useRef(false);

  useEffect(() => {
    if (open) {
      ref.current?.present();
      presented.current = true;
    } else if (presented.current) {
      // Only dismiss a presented modal: dismissing one that never opened leaves it stuck
      // "dismissing", and later present() calls are ignored.
      ref.current?.dismiss();
      presented.current = false;
    }
  }, [open]);

  const handleDismiss = useCallback(() => {
    presented.current = false;
    onOpenChange(false);
  }, [onOpenChange]);

  const renderBackdrop = useCallback(
    (props: BottomSheetBackdropProps) => <BottomSheetBackdrop {...props} appearsOnIndex={0} disappearsOnIndex={-1} />,
    []
  );

  return (
    <BottomSheetModal
      ref={ref}
      onDismiss={handleDismiss}
      backdropComponent={renderBackdrop}
      backgroundStyle={{ backgroundColor: StyleSheet.flatten(style)?.backgroundColor }}>
      <BottomSheetView style={style}>{children}</BottomSheetView>
    </BottomSheetModal>
  );
}

Native sheet with @expo/ui

Declarative sheets map directly onto the props. Native sheets measure React Native content at its natural width, so wrap the content in RNHostView to give it the sheet's size, and set snapPoints so the sheet has a height to fill:

import { StyleSheet, View } from 'react-native';
import { BottomSheet, RNHostView } from '@expo/ui';
import type { SheetProps } from 'react-native-video-toolkit';

export function NativeSheet({ open, onOpenChange, children, style }: SheetProps) {
  return (
    <BottomSheet
      isPresented={open}
      onDismiss={() => onOpenChange(false)}
      containerColor={StyleSheet.flatten(style)?.backgroundColor}
      contentPadding={0}
      snapPoints={['half', 'full']}>
      <RNHostView>
        <View style={[{ flex: 1, width: '100%' }, style]}>{children}</View>
      </RNHostView>
    </BottomSheet>
  );
}

Slider

One component serves both the progress bar and the volume control, and variant tells you which one is rendering it. Values are plain numbers: seconds for progress, 0 to 1 for volume. Call onValueChange while the user drags, and the toolkit seeks or changes the volume as the values arrive.

With the default slider, the progress bar follows playback through Reanimated shared values on the UI thread, so it doesn't re-render while the video plays. A replacement gets the position as a plain value, so the progress bar re-renders it on every progress event, twice a second while playing.

import Slider from '@react-native-community/slider';
import type { SliderProps } from 'react-native-video-toolkit';

export function NativeSlider({
  value,
  minimumValue,
  maximumValue,
  onValueChange,
  onSlidingStart,
  onSlidingComplete,
  width,
  colors,
}: SliderProps) {
  return (
    <Slider
      style={{ width: width ?? '100%', height: 32 }}
      value={value}
      minimumValue={minimumValue}
      // The duration is 0 until the video loads; keep the range valid meanwhile.
      maximumValue={Math.max(maximumValue, minimumValue + 1)}
      onValueChange={onValueChange}
      onSlidingStart={onSlidingStart}
      onSlidingComplete={onSlidingComplete}
      minimumTrackTintColor={colors.active}
      maximumTrackTintColor={colors.inactive}
      thumbTintColor={colors.thumb}
    />
  );
}

bufferedValue and colors.buffered are only set for progress; ignore them if your slider can't show a buffered track.

Replacing only one variant

The defaults are exported, so you can keep the toolkit's slider for the progress bar and use your own for the volume. The progress bar skips re-renders only for DefaultSlider itself, so a wrapper like this one still re-renders on every progress event:

import { DefaultSlider, type SliderProps } from 'react-native-video-toolkit';

export function Slider(props: SliderProps) {
  return props.variant === 'volume' ? <NativeSlider {...props} /> : <DefaultSlider {...props} />;
}

DefaultSheet, DefaultSpinner and DefaultPressFeedback are exported the same way.

Spinner

Shown over the video while it buffers. style centers it, using its own transform; if your spinner animates a transform such as a rotation, put the animation on an inner view so the two don't clash.

import { View } from 'react-native';
import type { SpinnerProps } from 'react-native-video-toolkit';

export function Spinner({ size, color, style }: SpinnerProps) {
  return (
    <View style={style} pointerEvents="none">
      <MyAnimatedRing size={size === 'large' ? 44 : 24} color={color} />
    </View>
  );
}

PressFeedback

Wraps the content of every control button to show that it was pressed. It's visual only: the toolkit detects the tap itself so it can coordinate with the player's gestures, so don't attach an onPress handler. Spread the remaining props (accessibility props, testID) onto your root view.

import { Pressable } from 'react-native';
import type { PressFeedbackProps } from 'react-native-video-toolkit';

// Pressable is only used for its `pressed` state; there is no onPress.
export function ScalePressFeedback({ children, color, style, ...viewProps }: PressFeedbackProps) {
  return (
    <Pressable
      {...viewProps}
      style={({ pressed }) => [
        { borderRadius: 50 },
        style,
        pressed && { backgroundColor: color, transform: [{ scale: 0.88 }] },
      ]}>
      {children}
    </Pressable>
  );
}

Using the components in your own controls

If you build custom controls, useVideoComponents() returns the active components, with your replacements applied, so your controls match the rest of the player:

import { useVideoComponents, useVolume, useTheme } from 'react-native-video-toolkit';

// A wider volume slider for a custom layout.
export function WideVolume() {
  const { Slider } = useVideoComponents();
  const { volume, setVolume } = useVolume();
  const theme = useTheme();

  return (
    <Slider
      variant="volume"
      value={volume}
      minimumValue={0}
      maximumValue={1}
      onValueChange={setVolume}
      height={4}
      thumbWidth={12}
      width={240}
      colors={{
        active: theme.colors.sliderTrackActive,
        inactive: theme.colors.sliderTrackInactive,
        thumb: theme.colors.sliderThumb,
      }}
    />
  );
}

On this page