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-maskpnpm dlx kosmesis add video-maskyarn dlx kosmesis add video-maskbunx kosmesis add video-maskCopy and paste the following code into your project.
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/csspnpm add @praxisjs/cssyarn add @praxisjs/cssbun add @praxisjs/cssCopy and paste the following code into your project.
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
| Prop | Type | Default |
|---|---|---|
src | string | — |
poster | string | — |
radius | number (px reveal radius) | 120 |
autoMove | boolean | false |
maskSrc | string (URL or data URI) | — |
maskSize | string | "contain" |
maskPosition | string | "center" |
maskRepeat | string | "no-repeat" |
class | string | — |
radius/autoMove only apply when maskSrc is unset — passing maskSrc switches the component
into the fixed-custom-shape mode described above.