Kosmesis
Components

Video Masking

A video revealed only inside a shape — a circle by default, or any custom SVG. Purely presentational — no Morphos equivalent.

Installation

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

Copy and paste the following code into your project.

video-mask.tsx
import { StatefulComponent } from "@praxisjs/core";import { Component, Prop, Ref, type Ref as RefType } from "@praxisjs/decorators";import { cn } from "@/lib/utils";export interface VideoMaskProps {  src: string;  poster?: string;  radius?: number;  autoMove?: boolean;  maskSrc?: string;  maskSize?: string;  maskPosition?: string;  maskRepeat?: string;  class?: string;}/** `--video-mask` is written imperatively, not via `@State` — it updates every frame/pointer-move. */@Component()export class VideoMask extends StatefulComponent {  @Prop() src!: string;  @Prop() poster?: string;  @Prop() radius = 120;  @Prop() autoMove = false;  @Prop() maskSrc?: string;  @Prop() maskSize?: string;  @Prop() maskPosition?: string;  @Prop() maskRepeat?: string;  @Prop() class?: string;  @Ref<HTMLDivElement>()  containerRef!: RefType<HTMLDivElement>;  private _rafId?: number;  private _startTime = 0;  onMount(): void {    if (this.maskSrc || !this.autoMove) return;    this._startTime = performance.now();    this._rafId = requestAnimationFrame(this._tick);  }  onUnmount(): void {    if (this._rafId !== undefined) cancelAnimationFrame(this._rafId);  }  private readonly _tick = (now: number) => {    const container = this.containerRef.current;    if (container) {      const elapsed = (now - this._startTime) / 1000;      const centerX = container.clientWidth / 2;      const centerY = container.clientHeight / 2;      const x = centerX + Math.sin(elapsed * 0.6) * centerX * 0.7;      const y = centerY + Math.sin(elapsed * 1.2) * centerY * 0.6;      this._setMask(x, y, this.radius);    }    this._rafId = requestAnimationFrame(this._tick);  };  private readonly _setMask = (x: number, y: number, radius: number) => {    const container = this.containerRef.current;    container?.style.setProperty("--video-mask", `radial-gradient(circle ${String(radius)}px at ${String(x)}px ${String(y)}px, black 99%, transparent 100%)`);  };  private readonly _handlePointerMove = (event: PointerEvent) => {    const container = this.containerRef.current;    if (!container) return;    const rect = container.getBoundingClientRect();    this._setMask(event.clientX - rect.left, event.clientY - rect.top, this.radius);  };  private readonly _handlePointerLeave = () => {    this._setMask(-9999, -9999, 0);  };  render() {    const { maskSrc, maskSize, maskPosition, maskRepeat } = this;    const videoMaskStyle = maskSrc      ? { maskImage: `url(${maskSrc})`, maskSize: maskSize ?? "contain", maskPosition: maskPosition ?? "center", maskRepeat: maskRepeat ?? "no-repeat" }      : { maskImage: "var(--video-mask)" };    return (      <div        ref={this.containerRef}        data-slot="video-mask"        class={cn("relative overflow-hidden bg-muted", this.class)}        style={maskSrc ? undefined : { "--video-mask": "radial-gradient(circle 0px at -9999px -9999px, black 99%, transparent 100%)" }}        onPointerMove={maskSrc || this.autoMove ? undefined : this._handlePointerMove}        onPointerLeave={maskSrc || this.autoMove ? undefined : this._handlePointerLeave}      >        <video          src={this.src}          poster={this.poster}          autoPlay          muted          loop          playsInline          class="absolute inset-0 size-full object-cover"          style={videoMaskStyle}        />      </div>    );  }}

Install the following dependencies:

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

Copy and paste the following code into your project.

video-mask.tsx
import { StatefulComponent } from "@praxisjs/core";import { cx, Stylesheet, Styled, tokenVars } from "@praxisjs/css";import { Component, Prop, Ref, type Ref as RefType } from "@praxisjs/decorators";import { KosmesisTokens } from "@/lib/kosmesis-theme";const t = tokenVars(KosmesisTokens);class VideoMaskStyles extends Stylesheet {  $root = this.css({ position: "relative", overflow: "hidden", backgroundColor: t.muted });  $video = this.css({ position: "absolute", inset: "0", width: "100%", height: "100%", objectFit: "cover" });}export interface VideoMaskProps {  src: string;  poster?: string;  radius?: number;  autoMove?: boolean;  maskSrc?: string;  maskSize?: string;  maskPosition?: string;  maskRepeat?: string;  class?: string;}/** `--video-mask` is written imperatively, not via `@State` — it updates every frame/pointer-move. */@Component()export class VideoMask extends StatefulComponent {  @Styled(VideoMaskStyles) $s!: VideoMaskStyles;  @Prop() src!: string;  @Prop() poster?: string;  @Prop() radius = 120;  @Prop() autoMove = false;  @Prop() maskSrc?: string;  @Prop() maskSize?: string;  @Prop() maskPosition?: string;  @Prop() maskRepeat?: string;  @Prop() class?: string;  @Ref<HTMLDivElement>()  containerRef!: RefType<HTMLDivElement>;  private _rafId?: number;  private _startTime = 0;  onMount(): void {    if (this.maskSrc || !this.autoMove) return;    this._startTime = performance.now();    this._rafId = requestAnimationFrame(this._tick);  }  onUnmount(): void {    if (this._rafId !== undefined) cancelAnimationFrame(this._rafId);  }  private readonly _tick = (now: number) => {    const container = this.containerRef.current;    if (container) {      const elapsed = (now - this._startTime) / 1000;      const centerX = container.clientWidth / 2;      const centerY = container.clientHeight / 2;      const x = centerX + Math.sin(elapsed * 0.6) * centerX * 0.7;      const y = centerY + Math.sin(elapsed * 1.2) * centerY * 0.6;      this._setMask(x, y, this.radius);    }    this._rafId = requestAnimationFrame(this._tick);  };  private readonly _setMask = (x: number, y: number, radius: number) => {    const container = this.containerRef.current;    container?.style.setProperty("--video-mask", `radial-gradient(circle ${String(radius)}px at ${String(x)}px ${String(y)}px, black 99%, transparent 100%)`);  };  private readonly _handlePointerMove = (event: PointerEvent) => {    const container = this.containerRef.current;    if (!container) return;    const rect = container.getBoundingClientRect();    this._setMask(event.clientX - rect.left, event.clientY - rect.top, this.radius);  };  private readonly _handlePointerLeave = () => {    this._setMask(-9999, -9999, 0);  };  render() {    const { maskSrc, maskSize, maskPosition, maskRepeat } = this;    const videoMaskStyle = maskSrc      ? { maskImage: `url(${maskSrc})`, maskSize: maskSize ?? "contain", maskPosition: maskPosition ?? "center", maskRepeat: maskRepeat ?? "no-repeat" }      : { maskImage: "var(--video-mask)" };    return (      <div        ref={this.containerRef}        data-slot="video-mask"        class={cx(this.$s.$root, this.class)}        style={maskSrc ? undefined : { "--video-mask": "radial-gradient(circle 0px at -9999px -9999px, black 99%, transparent 100%)" }}        onPointerMove={maskSrc || this.autoMove ? undefined : this._handlePointerMove}        onPointerLeave={maskSrc || this.autoMove ? undefined : this._handlePointerLeave}      >        <video          src={this.src}          poster={this.poster}          autoPlay          muted          loop          playsInline          class={this.$s.$video}          style={videoMaskStyle}        />      </div>    );  }}

Examples

About

Purely presentational — no Morphos equivalent. The reveal shape is a CSS custom property (--video-mask) written imperatively (same reasoning as Resizable's direct style writes: this needs to update every pointer-move event or animation frame, which would be wasteful to route through @State). The area outside the shape shows the container's background instead of the video.

By default the shape is a circle that follows the pointer (_handlePointerMove writes the mask straight from event.clientX/clientY). Set autoMove to drive that circle from a requestAnimationFrame loop instead — a Lissajous curve (cos/sin at different frequencies, tracing a figure eight rather than a plain circle) scaled to the container's own measured size, with no pointer involved at all. Useful for an ambient hero/background effect where you can't rely on the visitor moving their mouse over it (e.g. most of the time on mobile, or a section that's merely scrolled past).

Set maskSrc to reveal the video through any SVG (or raster image) instead of a circle — a fixed shape, applied once via mask-image: url(...), bypassing the --video-mask/pointer/ autoMove machinery entirely (no per-frame writes at all). Point it at a local file, a remote URL, or an inline data:image/svg+xml,... URI — whatever you pass becomes the reveal shape: your logo, an icon, a blob, text. The browser's default luminance masking means opaque white areas of the source are what's visible; a plain black fill instead masks everything out, since a transparent background already contributes zero regardless of color. maskSize/maskPosition/ maskRepeat (defaulting to "contain"/"center"/"no-repeat") tune how the shape fits the container, same as the matching CSS properties.

Usage

import { VideoMask } from "@/components/ui/video-mask";

<VideoMask class="h-64 w-full rounded-lg" src="/your-video.mp4" radius={120} />

{/* Ambient variant — sweeps on its own, ignores the pointer */}
<VideoMask class="h-64 w-full rounded-lg" src="/your-video.mp4" radius={120} autoMove />

{/* Custom shape — reveals through any SVG instead of a circle */}
<VideoMask class="h-64 w-full rounded-lg" src="/your-video.mp4" maskSrc="/your-logo.svg" />

Props

PropTypeDefault
srcstring
posterstring
radiusnumber (px reveal radius)120
autoMovebooleanfalse
maskSrcstring (URL or data URI)
maskSizestring"contain"
maskPositionstring"center"
maskRepeatstring"no-repeat"
classstring

radius/autoMove only apply when maskSrc is unset — passing maskSrc switches the component into the fixed-custom-shape mode described above.

On this page