Kosmesis
Components

Video Player

A native HTML5 video player with a custom control bar. Purely presentational — no Morphos equivalent, no media-chrome dependency.

Installation

npx kosmesis add video-player
pnpm dlx kosmesis add video-player
yarn dlx kosmesis add video-player
bunx kosmesis add video-player

Install the following dependencies:

npm install @morphos/icons
pnpm add @morphos/icons
yarn add @morphos/icons
bun add @morphos/icons

Copy and paste the following code into your project.

video-player.tsx
import { StatefulComponent, StatelessComponent } from "@praxisjs/core";import { Component, 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";function formatTime(seconds: number): string {  if (!Number.isFinite(seconds) || seconds < 0) return "0:00";  const total = Math.floor(seconds);  const minutes = Math.floor(total / 60);  const secs = total % 60;  return `${String(minutes)}:${String(secs).padStart(2, "0")}`;}const CONTROLS_HIDE_DELAY_MS = 2500;@Component()export class VideoPlayerState extends StatefulComponent {  @Ref<HTMLVideoElement>()  videoRef!: RefType<HTMLVideoElement>;  @State() playing = false;  @State() muted = false;  @State() volume = 1;  @State() currentTime = 0;  @State() duration = 0;  @State() buffering = false;  @State() controlsVisible = true;  private _hideTimer?: ReturnType<typeof setTimeout>;  get progress(): number {    return this.duration > 0 ? (this.currentTime / this.duration) * 100 : 0;  }  togglePlay(): void {    const video = this.videoRef.current;    if (!video) return;    if (video.paused) void video.play();    else video.pause();  }  toggleMute(): void {    const video = this.videoRef.current;    if (!video) return;    video.muted = !video.muted;  }  setVolume(volume: number): void {    const video = this.videoRef.current;    if (!video) return;    video.volume = Math.min(Math.max(volume, 0), 1);    if (volume > 0) video.muted = false;  }  seek(time: number): void {    const video = this.videoRef.current;    if (!video) return;    video.currentTime = Math.min(Math.max(time, 0), video.duration || 0);  }  seekBy(deltaSeconds: number): void {    const video = this.videoRef.current;    if (!video) return;    this.seek(video.currentTime + deltaSeconds);  }  showControls(): void {    this.controlsVisible = true;    this._scheduleHide();  }  hideControlsIfPlaying(): void {    if (this.playing) this.controlsVisible = false;  }  private _scheduleHide(): void {    if (this._hideTimer !== undefined) clearTimeout(this._hideTimer);    if (!this.playing) return;    this._hideTimer = setTimeout(() => {      this.controlsVisible = false;    }, CONTROLS_HIDE_DELAY_MS);  }  handlePlay(): void {    this.playing = true;    this._scheduleHide();  }  handlePause(): void {    this.playing = false;    this.controlsVisible = true;    if (this._hideTimer !== undefined) clearTimeout(this._hideTimer);  }  handleWaiting(): void {    this.buffering = true;  }  handlePlaying(): void {    this.buffering = false;  }  handleVolumeChange(): void {    const video = this.videoRef.current;    if (!video) return;    this.muted = video.muted;    this.volume = video.volume;  }  handleTimeUpdate(): void {    const video = this.videoRef.current;    if (!video) return;    this.currentTime = video.currentTime;  }  handleLoadedMetadata(): void {    const video = this.videoRef.current;    if (!video) return;    this.duration = video.duration;  }  onUnmount(): void {    if (this._hideTimer !== undefined) clearTimeout(this._hideTimer);  }  /** Pure state container — never mounted via JSX, only instantiated directly. */  render() {    return null;  }}export interface VideoPlayerProps {  state: VideoPlayerState;  src: string;  poster?: string;  loop?: boolean;  class?: string;  id?: string;  children?: Children;}@Component()export class VideoPlayer extends StatelessComponent<VideoPlayerProps> {  render() {    const { state, src, poster, loop, class: cls, id, children } = this.props;    return (      <div        id={id}        data-slot="video-player"        class={cn("group/player relative overflow-hidden rounded-lg bg-black", cls)}        onPointerMove={() => { state.showControls(); }}        onPointerLeave={() => { state.hideControlsIfPlaying(); }}      >        <video          ref={state.videoRef}          src={src}          poster={poster}          loop={loop}          playsInline          class="size-full"          onClick={() => { state.togglePlay(); }}          onPlay={() => { state.handlePlay(); }}          onPause={() => { state.handlePause(); }}          onEnded={() => { state.handlePause(); }}          onWaiting={() => { state.handleWaiting(); }}          onPlaying={() => { state.handlePlaying(); }}          onVolumeChange={() => { state.handleVolumeChange(); }}          onTimeUpdate={() => { state.handleTimeUpdate(); }}          onLoadedMetadata={() => { state.handleLoadedMetadata(); }}        />        {children}      </div>    );  }}export interface VideoPlayerOverlayProps {  state: VideoPlayerState;  class?: string;}/** A fresh button element each time the thunk re-runs, so the `zoom-in`/`fade-in` replays every pause. */@Component()export class VideoPlayerOverlay extends StatelessComponent<VideoPlayerOverlayProps> {  render() {    const { state, class: cls } = this.props;    return (      <div class={cn("pointer-events-none absolute inset-0 flex items-center justify-center", cls)}>        {() => {          if (state.buffering) {            return <Icon name="LoaderCircle" size={40} class="animate-spin text-white/90 drop-shadow" />;          }          if (!state.playing) {            return (              <button                type="button"                aria-label="Play"                class={cn(                  "pointer-events-auto flex size-16 animate-in items-center justify-center rounded-full",                  "bg-white/90 shadow-lg zoom-in-50 fade-in duration-200 ease-out",                  "transition-transform hover:scale-110 hover:bg-white active:scale-95",                )}                onClick={(event: MouseEvent) => {                  event.stopPropagation();                  state.togglePlay();                }}              >                <Icon name="Play" size={28} class="ml-1 text-black" />              </button>            );          }          return null;        }}      </div>    );  }}export interface VideoPlayerControlBarProps {  state: VideoPlayerState;  class?: string;  children?: Children;}@Component()export class VideoPlayerControlBar extends StatelessComponent<VideoPlayerControlBarProps> {  render() {    const { state, class: cls, children } = this.props;    return (      <div        data-slot="video-player-control-bar"        class={cn(          "absolute inset-x-0 bottom-0 flex items-center gap-1 bg-gradient-to-t from-black/80 to-transparent p-2",          "transition-[opacity,transform] duration-300 ease-out",          cls,        )}        style={() => ({          opacity: state.controlsVisible ? 1 : 0,          transform: state.controlsVisible ? "translateY(0)" : "translateY(100%)",          pointerEvents: state.controlsVisible ? "auto" : "none",        })}      >        {children}      </div>    );  }}export interface VideoPlayerButtonProps {  state: VideoPlayerState;  class?: string;}const VIDEO_PLAYER_BUTTON_CLASS =  "rounded-md p-2.5 text-white transition-transform duration-150 hover:scale-110 hover:bg-white/10 active:scale-90";@Component()export class VideoPlayerPlayButton extends StatelessComponent<VideoPlayerButtonProps> {  render() {    const { state, class: cls } = this.props;    return (      <button        type="button"        aria-label={() => (state.playing ? "Pause" : "Play")}        class={cn(VIDEO_PLAYER_BUTTON_CLASS, cls)}        onClick={() => { state.togglePlay(); }}      >        {() =>          state.playing ? (            <Icon name="Pause" size={16} class="animate-in zoom-in-50 duration-150" />          ) : (            <Icon name="Play" size={16} class="animate-in zoom-in-50 duration-150" />          )        }      </button>    );  }}export interface VideoPlayerSeekButtonProps {  state: VideoPlayerState;  seconds?: number;  class?: string;}@Component()export class VideoPlayerSeekBackwardButton extends StatelessComponent<VideoPlayerSeekButtonProps> {  render() {    const { state, seconds = 10, class: cls } = this.props;    return (      <button        type="button"        aria-label={`Seek backward ${String(seconds)} seconds`}        class={cn(VIDEO_PLAYER_BUTTON_CLASS, "active:-rotate-45", cls)}        onClick={() => { state.seekBy(-seconds); }}      >        <Icon name="Rewind" size={16} />      </button>    );  }}@Component()export class VideoPlayerSeekForwardButton extends StatelessComponent<VideoPlayerSeekButtonProps> {  render() {    const { state, seconds = 10, class: cls } = this.props;    return (      <button        type="button"        aria-label={`Seek forward ${String(seconds)} seconds`}        class={cn(VIDEO_PLAYER_BUTTON_CLASS, "active:rotate-45", cls)}        onClick={() => { state.seekBy(seconds); }}      >        <Icon name="FastForward" size={16} />      </button>    );  }}@Component()export class VideoPlayerMuteButton extends StatelessComponent<VideoPlayerButtonProps> {  render() {    const { state, class: cls } = this.props;    return (      <button        type="button"        aria-label={() => (state.muted ? "Unmute" : "Mute")}        class={cn(VIDEO_PLAYER_BUTTON_CLASS, cls)}        onClick={() => { state.toggleMute(); }}      >        {() =>          state.muted ? (            <Icon name="VolumeX" size={16} class="animate-in zoom-in-50 duration-150" />          ) : (            <Icon name="Volume2" size={16} class="animate-in zoom-in-50 duration-150" />          )        }      </button>    );  }}export interface VideoPlayerRangeProps {  state: VideoPlayerState;  class?: string;}const VIDEO_PLAYER_RANGE_CLASS = cn(  "h-1 cursor-pointer appearance-none rounded-full bg-white/25 transition-[height] duration-150 hover:h-1.5",  "[&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full",  "[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:opacity-0 [&::-webkit-slider-thumb]:shadow",  "[&::-webkit-slider-thumb]:transition-[opacity,transform] [&::-webkit-slider-thumb]:duration-150",  "hover:[&::-webkit-slider-thumb]:scale-110 hover:[&::-webkit-slider-thumb]:opacity-100",  "[&::-moz-range-thumb]:size-3 [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:rounded-full",  "[&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:opacity-0",  "hover:[&::-moz-range-thumb]:opacity-100",);@Component()export class VideoPlayerTimeRange extends StatelessComponent<VideoPlayerRangeProps> {  render() {    const { state, class: cls } = this.props;    return (      <input        type="range"        aria-label="Seek"        min={0}        max={() => state.duration || 0}        step={0.1}        value={() => state.currentTime}        class={cn(VIDEO_PLAYER_RANGE_CLASS, "flex-1", cls)}        style={() => ({ backgroundImage: `linear-gradient(to right, var(--color-primary) ${String(state.progress)}%, transparent ${String(state.progress)}%)` })}        onInput={(event: Event) => { state.seek(Number((event.target as HTMLInputElement).value)); }}      />    );  }}@Component()export class VideoPlayerVolumeRange extends StatelessComponent<VideoPlayerRangeProps> {  render() {    const { state, class: cls } = this.props;    return (      <input        type="range"        aria-label="Volume"        min={0}        max={1}        step={0.01}        value={() => state.volume}        class={cn(VIDEO_PLAYER_RANGE_CLASS, "w-16", cls)}        style={() => ({ backgroundImage: `linear-gradient(to right, var(--color-primary) ${String(state.muted ? 0 : state.volume * 100)}%, transparent ${String(state.muted ? 0 : state.volume * 100)}%)` })}        onInput={(event: Event) => { state.setVolume(Number((event.target as HTMLInputElement).value)); }}      />    );  }}@Component()export class VideoPlayerTimeDisplay extends StatelessComponent<VideoPlayerRangeProps> {  render() {    const { state, class: cls } = this.props;    return (      <span class={cn("px-1 font-mono text-xs text-white tabular-nums", cls)}>        {() => `${formatTime(state.currentTime)} / ${formatTime(state.duration)}`}      </span>    );  }}

Install the following dependencies:

npm install @praxisjs/css @morphos/icons
pnpm add @praxisjs/css @morphos/icons
yarn add @praxisjs/css @morphos/icons
bun add @praxisjs/css @morphos/icons

Copy and paste the following code into your project.

video-player.tsx
import { StatefulComponent, StatelessComponent } from "@praxisjs/core";import { cx, keyframes, Stylesheet, Styled, tokenVars } from "@praxisjs/css";import { Component, Ref, State, type Ref as RefType } from "@praxisjs/decorators";import type { Children } from "@praxisjs/shared";import { Icon } from "@morphos/icons";import { KosmesisTokens } from "@/lib/kosmesis-theme";const t = tokenVars(KosmesisTokens);function formatTime(seconds: number): string {  if (!Number.isFinite(seconds) || seconds < 0) return "0:00";  const total = Math.floor(seconds);  const minutes = Math.floor(total / 60);  const secs = total % 60;  return `${String(minutes)}:${String(secs).padStart(2, "0")}`;}const CONTROLS_HIDE_DELAY_MS = 2500;const spin = keyframes("kosmesis-video-player-spin", {  to: { transform: "rotate(360deg)" },});const zoomIn = keyframes("kosmesis-video-player-zoom-in", {  from: { transform: "scale(0.5)", opacity: "0" },  to: { transform: "scale(1)", opacity: "1" },});@Component()export class VideoPlayerState extends StatefulComponent {  @Ref<HTMLVideoElement>()  videoRef!: RefType<HTMLVideoElement>;  @State() playing = false;  @State() muted = false;  @State() volume = 1;  @State() currentTime = 0;  @State() duration = 0;  @State() buffering = false;  @State() controlsVisible = true;  private _hideTimer?: ReturnType<typeof setTimeout>;  get progress(): number {    return this.duration > 0 ? (this.currentTime / this.duration) * 100 : 0;  }  togglePlay(): void {    const video = this.videoRef.current;    if (!video) return;    if (video.paused) void video.play();    else video.pause();  }  toggleMute(): void {    const video = this.videoRef.current;    if (!video) return;    video.muted = !video.muted;  }  setVolume(volume: number): void {    const video = this.videoRef.current;    if (!video) return;    video.volume = Math.min(Math.max(volume, 0), 1);    if (volume > 0) video.muted = false;  }  seek(time: number): void {    const video = this.videoRef.current;    if (!video) return;    video.currentTime = Math.min(Math.max(time, 0), video.duration || 0);  }  seekBy(deltaSeconds: number): void {    const video = this.videoRef.current;    if (!video) return;    this.seek(video.currentTime + deltaSeconds);  }  showControls(): void {    this.controlsVisible = true;    this._scheduleHide();  }  hideControlsIfPlaying(): void {    if (this.playing) this.controlsVisible = false;  }  private _scheduleHide(): void {    if (this._hideTimer !== undefined) clearTimeout(this._hideTimer);    if (!this.playing) return;    this._hideTimer = setTimeout(() => {      this.controlsVisible = false;    }, CONTROLS_HIDE_DELAY_MS);  }  handlePlay(): void {    this.playing = true;    this._scheduleHide();  }  handlePause(): void {    this.playing = false;    this.controlsVisible = true;    if (this._hideTimer !== undefined) clearTimeout(this._hideTimer);  }  handleWaiting(): void {    this.buffering = true;  }  handlePlaying(): void {    this.buffering = false;  }  handleVolumeChange(): void {    const video = this.videoRef.current;    if (!video) return;    this.muted = video.muted;    this.volume = video.volume;  }  handleTimeUpdate(): void {    const video = this.videoRef.current;    if (!video) return;    this.currentTime = video.currentTime;  }  handleLoadedMetadata(): void {    const video = this.videoRef.current;    if (!video) return;    this.duration = video.duration;  }  onUnmount(): void {    if (this._hideTimer !== undefined) clearTimeout(this._hideTimer);  }  /** Pure state container — never mounted via JSX, only instantiated directly. */  render() {    return null;  }}class VideoPlayerStyles extends Stylesheet {  $root = this.css({ position: "relative", overflow: "hidden", borderRadius: "0.5rem", backgroundColor: "black" });  $video = this.css({ height: "100%", width: "100%" });  $overlay = this.css({ pointerEvents: "none", position: "absolute", inset: "0", display: "flex", alignItems: "center", justifyContent: "center" });  $spinner = this.css({ color: "rgb(255 255 255 / 0.9)", filter: "drop-shadow(0 1px 2px rgb(0 0 0 / 0.3))", animation: `${spin} 1s linear infinite` });  $overlayButton = this.css({    pointerEvents: "auto",    display: "flex",    height: "4rem",    width: "4rem",    alignItems: "center",    justifyContent: "center",    borderRadius: "9999px",    border: "none",    backgroundColor: "rgb(255 255 255 / 0.9)",    boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.2)",    cursor: "pointer",    animation: `${zoomIn} 200ms ease-out both`,    transition: "transform 150ms ease-out, background-color 150ms ease-out",  })    .on("&:hover", { transform: "scale(1.1)", backgroundColor: "white" })    .on("&:active", { transform: "scale(0.95)" });  $controlBar = this.css({    position: "absolute",    insetInline: "0",    bottom: "0",    display: "flex",    alignItems: "center",    gap: "0.25rem",    backgroundImage: "linear-gradient(to top, rgb(0 0 0 / 0.8), transparent)",    padding: "0.5rem",    transition: "opacity 300ms ease-out, transform 300ms ease-out",  });  $button = this.css({    borderRadius: "0.375rem",    padding: "0.625rem",    color: "white",    border: "none",    background: "none",    cursor: "pointer",    transition: "transform 150ms ease-out, background-color 150ms ease-out",  })    .on("&:hover", { transform: "scale(1.1)", backgroundColor: "rgb(255 255 255 / 0.1)" })    .on("&:active", { transform: "scale(0.9)" });  $icon = this.css({ animation: `${zoomIn} 150ms ease-out both` });  $range = this.css({    height: "0.25rem",    cursor: "pointer",    appearance: "none",    borderRadius: "9999px",    backgroundColor: "rgb(255 255 255 / 0.25)",    transition: "height 150ms ease-out",  })    .on("&:hover", { height: "0.375rem" })    .on("&::-webkit-slider-thumb", {      appearance: "none",      height: "0.75rem",      width: "0.75rem",      borderRadius: "9999px",      backgroundColor: "white",      opacity: "0",      boxShadow: "0 1px 2px 0 rgb(0 0 0 / 0.2)",      transition: "opacity 150ms ease-out, transform 150ms ease-out",    })    .on("&:hover::-webkit-slider-thumb", { opacity: "1", transform: "scale(1.1)" })    .on("&::-moz-range-thumb", {      appearance: "none",      height: "0.75rem",      width: "0.75rem",      border: "none",      borderRadius: "9999px",      backgroundColor: "white",      opacity: "0",    })    .on("&:hover::-moz-range-thumb", { opacity: "1" });  $timeRange = this.css({ flex: "1" });  $volumeRange = this.css({ width: "4rem" });  $timeDisplay = this.css({    padding: "0 0.25rem",    fontFamily: "var(--font-mono, monospace)",    fontSize: "0.75rem",    color: "white",    fontVariantNumeric: "tabular-nums",  });}export interface VideoPlayerProps {  state: VideoPlayerState;  src: string;  poster?: string;  loop?: boolean;  class?: string;  id?: string;  children?: Children;}@Component()export class VideoPlayer extends StatelessComponent<VideoPlayerProps> {  @Styled(VideoPlayerStyles) $s!: VideoPlayerStyles;  render() {    const { state, src, poster, loop, class: cls, id, children } = this.props;    return (      <div        id={id}        data-slot="video-player"        class={cx(this.$s.$root, cls)}        onPointerMove={() => { state.showControls(); }}        onPointerLeave={() => { state.hideControlsIfPlaying(); }}      >        <video          ref={state.videoRef}          src={src}          poster={poster}          loop={loop}          playsInline          class={this.$s.$video}          onClick={() => { state.togglePlay(); }}          onPlay={() => { state.handlePlay(); }}          onPause={() => { state.handlePause(); }}          onEnded={() => { state.handlePause(); }}          onWaiting={() => { state.handleWaiting(); }}          onPlaying={() => { state.handlePlaying(); }}          onVolumeChange={() => { state.handleVolumeChange(); }}          onTimeUpdate={() => { state.handleTimeUpdate(); }}          onLoadedMetadata={() => { state.handleLoadedMetadata(); }}        />        {children}      </div>    );  }}export interface VideoPlayerOverlayProps {  state: VideoPlayerState;  class?: string;}/** A fresh button element each time the thunk re-runs, so the scale+fade keyframe replays every pause. */@Component()export class VideoPlayerOverlay extends StatelessComponent<VideoPlayerOverlayProps> {  @Styled(VideoPlayerStyles) $s!: VideoPlayerStyles;  render() {    const { state, class: cls } = this.props;    return (      <div class={cx(this.$s.$overlay, cls)}>        {() => {          if (state.buffering) {            return <Icon name="LoaderCircle" size={40} class={this.$s.$spinner} />;          }          if (!state.playing) {            return (              <button                type="button"                aria-label="Play"                class={this.$s.$overlayButton}                onClick={(event: MouseEvent) => {                  event.stopPropagation();                  state.togglePlay();                }}              >                <Icon name="Play" size={28} style={{ marginLeft: "2px" }} color="black" />              </button>            );          }          return null;        }}      </div>    );  }}export interface VideoPlayerControlBarProps {  state: VideoPlayerState;  class?: string;  children?: Children;}@Component()export class VideoPlayerControlBar extends StatelessComponent<VideoPlayerControlBarProps> {  @Styled(VideoPlayerStyles) $s!: VideoPlayerStyles;  render() {    const { state, class: cls, children } = this.props;    return (      <div        data-slot="video-player-control-bar"        class={cx(this.$s.$controlBar, cls)}        style={() => ({          opacity: state.controlsVisible ? 1 : 0,          transform: state.controlsVisible ? "translateY(0)" : "translateY(100%)",          pointerEvents: state.controlsVisible ? "auto" : "none",        })}      >        {children}      </div>    );  }}export interface VideoPlayerButtonProps {  state: VideoPlayerState;  class?: string;}@Component()export class VideoPlayerPlayButton extends StatelessComponent<VideoPlayerButtonProps> {  @Styled(VideoPlayerStyles) $s!: VideoPlayerStyles;  render() {    const { state, class: cls } = this.props;    return (      <button        type="button"        aria-label={() => (state.playing ? "Pause" : "Play")}        class={cx(this.$s.$button, cls)}        onClick={() => { state.togglePlay(); }}      >        {() => (state.playing ? <Icon name="Pause" size={16} class={this.$s.$icon} /> : <Icon name="Play" size={16} class={this.$s.$icon} />)}      </button>    );  }}export interface VideoPlayerSeekButtonProps {  state: VideoPlayerState;  seconds?: number;  class?: string;}@Component()export class VideoPlayerSeekBackwardButton extends StatelessComponent<VideoPlayerSeekButtonProps> {  @Styled(VideoPlayerStyles) $s!: VideoPlayerStyles;  render() {    const { state, seconds = 10, class: cls } = this.props;    return (      <button        type="button"        aria-label={`Seek backward ${String(seconds)} seconds`}        class={cx(this.$s.$button, cls)}        onClick={() => { state.seekBy(-seconds); }}      >        <Icon name="Rewind" size={16} />      </button>    );  }}@Component()export class VideoPlayerSeekForwardButton extends StatelessComponent<VideoPlayerSeekButtonProps> {  @Styled(VideoPlayerStyles) $s!: VideoPlayerStyles;  render() {    const { state, seconds = 10, class: cls } = this.props;    return (      <button        type="button"        aria-label={`Seek forward ${String(seconds)} seconds`}        class={cx(this.$s.$button, cls)}        onClick={() => { state.seekBy(seconds); }}      >        <Icon name="FastForward" size={16} />      </button>    );  }}@Component()export class VideoPlayerMuteButton extends StatelessComponent<VideoPlayerButtonProps> {  @Styled(VideoPlayerStyles) $s!: VideoPlayerStyles;  render() {    const { state, class: cls } = this.props;    return (      <button        type="button"        aria-label={() => (state.muted ? "Unmute" : "Mute")}        class={cx(this.$s.$button, cls)}        onClick={() => { state.toggleMute(); }}      >        {() => (state.muted ? <Icon name="VolumeX" size={16} class={this.$s.$icon} /> : <Icon name="Volume2" size={16} class={this.$s.$icon} />)}      </button>    );  }}export interface VideoPlayerRangeProps {  state: VideoPlayerState;  class?: string;}@Component()export class VideoPlayerTimeRange extends StatelessComponent<VideoPlayerRangeProps> {  @Styled(VideoPlayerStyles) $s!: VideoPlayerStyles;  render() {    const { state, class: cls } = this.props;    return (      <input        type="range"        aria-label="Seek"        min={0}        max={() => state.duration || 0}        step={0.1}        value={() => state.currentTime}        class={cx(this.$s.$range, this.$s.$timeRange, cls)}        style={() => ({ backgroundImage: `linear-gradient(to right, ${t.primary} ${String(state.progress)}%, transparent ${String(state.progress)}%)` })}        onInput={(event: Event) => { state.seek(Number((event.target as HTMLInputElement).value)); }}      />    );  }}@Component()export class VideoPlayerVolumeRange extends StatelessComponent<VideoPlayerRangeProps> {  @Styled(VideoPlayerStyles) $s!: VideoPlayerStyles;  render() {    const { state, class: cls } = this.props;    return (      <input        type="range"        aria-label="Volume"        min={0}        max={1}        step={0.01}        value={() => state.volume}        class={cx(this.$s.$range, this.$s.$volumeRange, cls)}        style={() => ({          backgroundImage: `linear-gradient(to right, ${t.primary} ${String(state.muted ? 0 : state.volume * 100)}%, transparent ${String(state.muted ? 0 : state.volume * 100)}%)`,        })}        onInput={(event: Event) => { state.setVolume(Number((event.target as HTMLInputElement).value)); }}      />    );  }}@Component()export class VideoPlayerTimeDisplay extends StatelessComponent<VideoPlayerRangeProps> {  @Styled(VideoPlayerStyles) $s!: VideoPlayerStyles;  render() {    const { state, class: cls } = this.props;    return (      <span class={cx(this.$s.$timeDisplay, cls)}>        {() => `${formatTime(state.currentTime)} / ${formatTime(state.duration)}`}      </span>    );  }}

Examples

About

Purely presentational — no Morphos equivalent, and deliberately avoids a runtime dependency like media-chrome: the native <video> element already fires every event (play/pause/timeupdate/loadedmetadata/volumechange/waiting/playing) a custom control bar needs. VideoPlayerState mirrors those events into a handful of @State fields and exposes imperative togglePlay/seek/seekBy/setVolume/toggleMute methods that write straight back to the <video> element — instantiated directly and passed to VideoPlayer and every control via a state prop, the same convention as CarouselState. Clicking the video itself also toggles play/pause.

Every control has a hover/active micro-interaction (buttons scale up on hover and down on press, the play/pause and mute/unmute icons pop in with a quick zoom whenever they swap), and the seek and volume sliders reveal a thumb and grow slightly taller on hover, with the filled portion drawn as an animated gradient rather than relying on the browser's native (and inconsistently-styled) range appearance.

VideoPlayerControlBar auto-hides itself — after state.showControls() runs (wired to VideoPlayer's own onPointerMove), a timer fades it back out 2.5s after the pointer stops moving, but only while playing; pausing cancels the timer and forces it back on immediately, since there's no reason to hide controls over a paused frame. VideoPlayerOverlay is the center-of-frame indicator every native player has: a spinner while buffering (the waiting event), or a big pop-in play button while paused — never both at once.

Usage

import {
  VideoPlayer,
  VideoPlayerState,
  VideoPlayerControlBar,
  VideoPlayerMuteButton,
  VideoPlayerOverlay,
  VideoPlayerPlayButton,
  VideoPlayerSeekBackwardButton,
  VideoPlayerSeekForwardButton,
  VideoPlayerTimeDisplay,
  VideoPlayerTimeRange,
  VideoPlayerVolumeRange,
} from "@/components/ui/video-player";

const state = new VideoPlayerState();

<VideoPlayer state={state} src="/videos/sample.mp4">
  <VideoPlayerOverlay state={state} />
  <VideoPlayerControlBar state={state}>
    <VideoPlayerSeekBackwardButton state={state} />
    <VideoPlayerPlayButton state={state} />
    <VideoPlayerSeekForwardButton state={state} />
    <VideoPlayerTimeDisplay state={state} />
    <VideoPlayerTimeRange state={state} />
    <VideoPlayerMuteButton state={state} />
    <VideoPlayerVolumeRange state={state} />
  </VideoPlayerControlBar>
</VideoPlayer>

Props

VideoPlayer

PropTypeDefault
stateVideoPlayerState
srcstring
posterstring
loopboolean
classstring

VideoPlayerSeekBackwardButton / VideoPlayerSeekForwardButton

PropTypeDefault
stateVideoPlayerState
secondsnumber10
classstring

VideoPlayerOverlay / VideoPlayerControlBar / VideoPlayerPlayButton / VideoPlayerMuteButton / VideoPlayerTimeRange / VideoPlayerVolumeRange / VideoPlayerTimeDisplay

PropTypeDefault
stateVideoPlayerState
classstring

VideoPlayerState exposes playing, muted, volume, currentTime, duration, buffering, controlsVisible, and a computed progress (0–100) — read directly off the instance if you're building fully custom controls.

On this page