An SEO-friendly WebGL image or video with scroll-velocity driven bend and chromatic aberration.
npx atelier-ui add curve-medianpm install three @react-three/fiber @react-three/dreiimport { shaderMaterial } from "@react-three/drei"
import { extend, type ThreeElement, useFrame } from "@react-three/fiber"
import { useEffect, useRef } from "react"
import { MathUtils, Texture } from "three"
import { WebglImage } from "../webgl-image/webgl-image"
import { WebglVideo } from "../webgl-video/webgl-video"
declare module "@react-three/fiber" {
interface ThreeElements {
curveMediaMat: ThreeElement<typeof CurveMediaMat>
}
}
const vertexShader = /* glsl */ `
precision highp float;
varying vec2 vUv;
uniform float uVelocity;
uniform float uAmplitude;
const float PI = 3.14159265;
void main() {
vUv = uv;
vec3 pos = position;
float bend = sin(uv.x * PI);
pos.y += bend * uVelocity * uAmplitude;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
`
const fragmentShader = /* glsl */ `
precision highp float;
varying vec2 vUv;
uniform sampler2D uMap;
uniform float uVelocity;
uniform float uAberration;
void main() {
float shift = uVelocity * uAberration;
vec2 offset = vec2(0.0, shift);
float r = texture2D(uMap, vUv + offset).r;
float g = texture2D(uMap, vUv).g;
float b = texture2D(uMap, vUv - offset).b;
gl_FragColor = vec4(r, g, b, 1.0);
}
`
const CurveMediaMat = shaderMaterial(
{
uMap: new Texture(),
uVelocity: 0,
uAmplitude: 0,
uAberration: 0,
},
vertexShader,
fragmentShader,
)
extend({ CurveMediaMat })
export type CurveEffectProps = {
amplitude?: number
aberration?: number
smoothing?: number
segments?: number
webglEnabled?: boolean
}
type CurveMediaMaterialProps = {
map: Texture
} & Required<Pick<CurveEffectProps, "amplitude" | "aberration" | "smoothing">>
type CurveMediaImageProps = CurveEffectProps & {
type?: "image"
src: string
alt: string
} & Omit<React.ComponentPropsWithoutRef<"img">, "src" | "alt">
type CurveMediaVideoProps = CurveEffectProps & {
type: "video"
src: string
} & Omit<React.ComponentPropsWithoutRef<"video">, "src">
export type CurveMediaProps = CurveMediaImageProps | CurveMediaVideoProps
function CurveMediaMaterial({ map, amplitude, aberration, smoothing }: CurveMediaMaterialProps) {
const ref = useRef<InstanceType<typeof CurveMediaMat>>(null)
const lastScrollY = useRef(0)
const velocity = useRef(0)
useEffect(() => {
lastScrollY.current = window.scrollY
}, [])
useFrame((_, delta) => {
const material = ref.current
if (!material) return
const current = window.scrollY
const instantDelta = current - lastScrollY.current
lastScrollY.current = current
if (delta === 0) return
const target = instantDelta / delta / window.innerHeight
velocity.current = MathUtils.damp(velocity.current, target, smoothing, delta)
material.uVelocity = velocity.current
})
return (
<curveMediaMat
ref={ref}
key={CurveMediaMat.key}
uMap={map}
uAmplitude={amplitude}
uAberration={aberration}
transparent
/>
)
}
export function CurveMedia(props: CurveMediaProps) {
const {
amplitude = 0.03,
aberration = 0.003,
smoothing = 6,
segments = 32,
webglEnabled = true,
...rest
} = props
// Scroll-velocity is read from the window each frame, so the same material
// runs on both the image and video primitives (they share the WebGL plane contract).
const material = (map: Texture) => (
<CurveMediaMaterial
map={map}
amplitude={amplitude}
aberration={aberration}
smoothing={smoothing}
/>
)
if (rest.type === "video") {
const { type: _type, ...videoProps } = rest
return (
<WebglVideo
segments={segments}
webglEnabled={webglEnabled}
material={material}
{...videoProps}
/>
)
}
const { type: _type, ...imageProps } = rest
return (
<WebglImage
segments={segments}
webglEnabled={webglEnabled}
material={material}
{...imageProps}
/>
)
}
import { useTexture } from "@react-three/drei"
import { useFrame, useThree } from "@react-three/fiber"
import {
type ComponentRef,
type RefObject,
useEffect,
useLayoutEffect,
useMemo,
useRef,
} from "react"
import { type Mesh, type Texture, Vector2 } from "three"
import { webglTeleport } from "../webgl-portal/webgl-portal"
export type Pointer = {
uv: Vector2
texUv: Vector2
repeat: Vector2
hover: number
}
type WebglImageProps = {
src: string
alt: string
material?: (map: Texture, pointer: Pointer) => React.ReactNode
webglEnabled?: boolean
segments?: number
zIndex?: number
/**
* Re-measures the DOM rect every frame so the plane follows animated parents (motion, parallax).
* Costs one layout read per frame, so only enable it when needed.
*/
autoReflow?: boolean
} & Omit<React.ComponentPropsWithoutRef<"img">, "children" | "src" | "alt">
type PlaneProps = {
el: RefObject<HTMLImageElement | null>
src: string
segments: number
material?: (map: Texture, pointer: Pointer) => React.ReactNode
pointer: Pointer
uvFit: RefObject<{ x: number; y: number }>
zIndex: number
autoReflow: boolean
}
function Plane({ el, src, segments, material, pointer, uvFit, zIndex, autoReflow }: PlaneProps) {
const mesh = useRef<Mesh>(null)
const texture = useTexture(src)
const size = useThree((s) => s.size)
const viewport = useThree((s) => s.viewport)
const fitScale = useRef({ x: 1, y: 1 })
const bounds = useRef({ x: 0, y: 0, width: 0, height: 0 })
useLayoutEffect(() => {
const target = el.current
if (!target) return
const measure = () => {
const m = mesh.current
if (!m) return
/*
* Rect in document coords so viewport position later needs only
* window.scrollX/Y, instead of re-measuring bounds every render.
*/
const rect = target.getBoundingClientRect()
bounds.current.x = rect.left + window.scrollX
bounds.current.y = rect.top + window.scrollY
bounds.current.width = rect.width
bounds.current.height = rect.height
/*
* Replicate CSS object-fit: cover crops via UV repeat/offset,
* contain shrinks the mesh scale (UVs alone can't letterbox).
*/
const image = texture.image as HTMLImageElement
const objectFit = getComputedStyle(target).objectFit
const planeAspect = rect.width / rect.height
const imageAspect = image.width / image.height
let repeatU = 1
let repeatV = 1
fitScale.current.x = 1
fitScale.current.y = 1
if (objectFit === "cover") {
if (planeAspect > imageAspect) {
repeatV = imageAspect / planeAspect
} else {
repeatU = planeAspect / imageAspect
}
} else if (objectFit === "contain") {
if (planeAspect > imageAspect) {
fitScale.current.x = imageAspect / planeAspect
} else {
fitScale.current.y = planeAspect / imageAspect
}
}
const offsetU = (1 - repeatU) / 2
const offsetV = (1 - repeatV) / 2
pointer.repeat.set(repeatU, repeatV)
uvFit.current.x = repeatU / fitScale.current.x
uvFit.current.y = repeatV / fitScale.current.y
const uvAttribute = m.geometry.attributes.uv
for (let iy = 0; iy <= segments; iy++) {
for (let ix = 0; ix <= segments; ix++) {
const idx = iy * (segments + 1) + ix
const u = ix / segments
const v = 1 - iy / segments
uvAttribute.setXY(idx, u * repeatU + offsetU, v * repeatV + offsetV)
}
}
uvAttribute.needsUpdate = true
}
measure()
const ro = new ResizeObserver(measure)
ro.observe(target)
ro.observe(document.body)
return () => ro.disconnect()
}, [el, texture, segments, uvFit, pointer])
useFrame(() => {
const m = mesh.current
if (!m) return
const pxToWorld = viewport.height / size.height
/*
* autoReflow re-reads the rect each frame so the mesh follows parent
* CSS transforms like parallax. One layout read per frame.
*/
if (autoReflow && el.current) {
const rect = el.current.getBoundingClientRect()
m.position.x = (rect.left + rect.width / 2 - size.width / 2) * pxToWorld
m.position.y = -(rect.top + rect.height / 2 - size.height / 2) * pxToWorld
m.scale.x = rect.width * pxToWorld * fitScale.current.x
m.scale.y = rect.height * pxToWorld * fitScale.current.y
return
}
const { x, y, width, height } = bounds.current
m.position.x = (x + width / 2 - window.scrollX - size.width / 2) * pxToWorld
m.position.y = -(y + height / 2 - window.scrollY - size.height / 2) * pxToWorld
m.scale.x = width * pxToWorld * fitScale.current.x
m.scale.y = height * pxToWorld * fitScale.current.y
})
return (
<mesh ref={mesh} renderOrder={zIndex}>
<planeGeometry args={[1, 1, segments, segments]} />
{material ? (
material(texture, pointer)
) : (
<meshBasicMaterial map={texture} transparent />
)}
</mesh>
)
}
export function WebglImage({
src,
alt,
className,
style,
material,
webglEnabled = true,
segments = 1,
zIndex = 0,
autoReflow = false,
...rest
}: WebglImageProps) {
const el = useRef<ComponentRef<"img">>(null)
const uvFit = useRef({ x: 1, y: 1 })
const pointer = useMemo<Pointer>(() => {
return {
uv: new Vector2(0.5, 0.5),
texUv: new Vector2(0.5, 0.5),
repeat: new Vector2(1, 1),
hover: 0,
}
}, [])
useEffect(() => {
if (!webglEnabled) return
const target = el.current
if (!target) return
/*
* Pointer events still fire on the DOM element through opacity:0,
* so the browser tells us when the cursor is over it.
*/
const onMove = (e: PointerEvent) => {
const { width, left, top, height } = target.getBoundingClientRect()
const x = (e.clientX - left) / width
const y = 1 - (e.clientY - top) / height
const fit = uvFit.current
pointer.uv.set(x, y)
pointer.texUv.set(x * fit.x + (1 - fit.x) / 2, y * fit.y + (1 - fit.y) / 2)
}
const onEnter = () => (pointer.hover = 1)
const onLeave = () => (pointer.hover = 0)
target.addEventListener("pointermove", onMove)
target.addEventListener("pointerenter", onEnter)
target.addEventListener("pointerleave", onLeave)
/*
* Hover in too fast and pointerenter fires before these listeners
* attach, so seed hover from the live :hover state instead.
*/
if (target.matches(":hover")) pointer.hover = 1
return () => {
target.removeEventListener("pointermove", onMove)
target.removeEventListener("pointerenter", onEnter)
target.removeEventListener("pointerleave", onLeave)
}
}, [webglEnabled, pointer])
return (
<>
<img
ref={el}
src={src}
alt={alt}
className={className}
style={webglEnabled ? { ...style, opacity: 0 } : style}
{...rest}
/>
{webglEnabled && (
<webglTeleport.In>
<Plane
el={el}
src={src}
segments={segments}
material={material}
pointer={pointer}
uvFit={uvFit}
zIndex={zIndex}
autoReflow={autoReflow}
/>
</webglTeleport.In>
)}
</>
)
}
import { useFrame, useThree } from "@react-three/fiber"
import {
type ComponentRef,
type RefObject,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react"
import { type Mesh, SRGBColorSpace, type Texture, Vector2, VideoTexture } from "three"
import { webglTeleport } from "../webgl-portal/webgl-portal"
export type Pointer = {
uv: Vector2
texUv: Vector2
repeat: Vector2
hover: number
}
type WebglVideoProps = {
src: string
material?: (map: Texture, pointer: Pointer) => React.ReactNode
webglEnabled?: boolean
segments?: number
zIndex?: number
/**
* Re-measures the DOM rect every frame so the plane follows animated parents (motion, parallax).
* Costs one layout read per frame, so only enable it when needed.
*/
autoReflow?: boolean
} & Omit<React.ComponentPropsWithoutRef<"video">, "children" | "src">
type PlaneProps = {
el: RefObject<HTMLVideoElement | null>
segments: number
material?: (map: Texture, pointer: Pointer) => React.ReactNode
pointer: Pointer
uvFit: RefObject<{ x: number; y: number }>
zIndex: number
autoReflow: boolean
}
function Plane({ el, segments, material, pointer, uvFit, zIndex, autoReflow }: PlaneProps) {
const mesh = useRef<Mesh>(null)
const [texture, setTexture] = useState<VideoTexture | null>(null)
const size = useThree((state) => state.size)
const viewport = useThree((state) => state.viewport)
const fitScale = useRef({ x: 1, y: 1 })
const bounds = useRef({ x: 0, y: 0, width: 0, height: 0 })
useLayoutEffect(() => {
const video = el.current
if (!video) return
/*
* Build the texture from the DOM <video> itself so a single element
* decodes once. VideoTexture pulls each new frame from it.
*/
const videoTexture = new VideoTexture(video)
videoTexture.colorSpace = SRGBColorSpace
setTexture(videoTexture)
return () => videoTexture.dispose()
}, [el])
useLayoutEffect(() => {
const target = el.current
if (!target || !texture) return
const measure = () => {
const m = mesh.current
if (!m) return
/*
* Rect in document coords so viewport position later needs only
* window.scrollX/Y, instead of re-measuring bounds every render.
*/
const rect = target.getBoundingClientRect()
bounds.current.x = rect.left + window.scrollX
bounds.current.y = rect.top + window.scrollY
bounds.current.width = rect.width
bounds.current.height = rect.height
/*
* Replicate CSS object-fit: cover crops via UV repeat/offset,
* contain shrinks the mesh scale (UVs alone can't letterbox).
*/
const video = texture.image as HTMLVideoElement
const objectFit = getComputedStyle(target).objectFit
const planeAspect = rect.width / rect.height
const videoAspect = video.videoWidth / video.videoHeight
let repeatU = 1
let repeatV = 1
fitScale.current.x = 1
fitScale.current.y = 1
/* videoWidth/Height are 0 until metadata loads, so skip cropping until then. */
if (video.videoWidth > 0) {
if (objectFit === "cover") {
if (planeAspect > videoAspect) {
repeatV = videoAspect / planeAspect
} else {
repeatU = planeAspect / videoAspect
}
} else if (objectFit === "contain") {
if (planeAspect > videoAspect) {
fitScale.current.x = videoAspect / planeAspect
} else {
fitScale.current.y = planeAspect / videoAspect
}
}
}
const offsetU = (1 - repeatU) / 2
const offsetV = (1 - repeatV) / 2
pointer.repeat.set(repeatU, repeatV)
uvFit.current.x = repeatU / fitScale.current.x
uvFit.current.y = repeatV / fitScale.current.y
const uvAttribute = m.geometry.attributes.uv
for (let iy = 0; iy <= segments; iy++) {
for (let ix = 0; ix <= segments; ix++) {
const idx = iy * (segments + 1) + ix
const u = ix / segments
const v = 1 - iy / segments
uvAttribute.setXY(idx, u * repeatU + offsetU, v * repeatV + offsetV)
}
}
uvAttribute.needsUpdate = true
}
measure()
/* Re-measure once the video reports its intrinsic size. */
target.addEventListener("loadedmetadata", measure)
target.addEventListener("resize", measure)
const ro = new ResizeObserver(measure)
ro.observe(target)
ro.observe(document.body)
return () => {
ro.disconnect()
target.removeEventListener("loadedmetadata", measure)
target.removeEventListener("resize", measure)
}
}, [el, texture, segments, uvFit, pointer])
useFrame(() => {
const m = mesh.current
if (!m) return
/* Browsers without requestVideoFrameCallback need an explicit pull. */
texture?.update()
const pxToWorld = viewport.height / size.height
/*
* autoReflow re-reads the rect each frame so the mesh follows parent
* CSS transforms like parallax. One layout read per frame.
*/
if (autoReflow && el.current) {
const rect = el.current.getBoundingClientRect()
m.position.x = (rect.left + rect.width / 2 - size.width / 2) * pxToWorld
m.position.y = -(rect.top + rect.height / 2 - size.height / 2) * pxToWorld
m.scale.x = rect.width * pxToWorld * fitScale.current.x
m.scale.y = rect.height * pxToWorld * fitScale.current.y
return
}
const { x, y, width, height } = bounds.current
m.position.x = (x + width / 2 - window.scrollX - size.width / 2) * pxToWorld
m.position.y = -(y + height / 2 - window.scrollY - size.height / 2) * pxToWorld
m.scale.x = width * pxToWorld * fitScale.current.x
m.scale.y = height * pxToWorld * fitScale.current.y
})
if (!texture) return null
return (
<mesh ref={mesh} renderOrder={zIndex}>
<planeGeometry args={[1, 1, segments, segments]} />
{material ? (
material(texture, pointer)
) : (
<meshBasicMaterial map={texture} transparent />
)}
</mesh>
)
}
export function WebglVideo({
src,
className,
style,
material,
webglEnabled = true,
segments = 1,
zIndex = 0,
autoReflow = false,
autoPlay = true,
muted = true,
loop = true,
playsInline = true,
...rest
}: WebglVideoProps) {
const el = useRef<ComponentRef<"video">>(null)
const uvFit = useRef({ x: 1, y: 1 })
const pointer = useMemo<Pointer>(() => {
return {
uv: new Vector2(0.5, 0.5),
texUv: new Vector2(0.5, 0.5),
repeat: new Vector2(1, 1),
hover: 0,
}
}, [])
useEffect(() => {
if (!webglEnabled) return
const target = el.current
if (!target) return
/*
* Pointer events still fire on the DOM element through opacity:0,
* so the browser tells us when the cursor is over it.
*/
const onMove = (event: PointerEvent) => {
const { width, left, top, height } = target.getBoundingClientRect()
const x = (event.clientX - left) / width
const y = 1 - (event.clientY - top) / height
const fit = uvFit.current
pointer.uv.set(x, y)
pointer.texUv.set(x * fit.x + (1 - fit.x) / 2, y * fit.y + (1 - fit.y) / 2)
}
const onEnter = () => (pointer.hover = 1)
const onLeave = () => (pointer.hover = 0)
target.addEventListener("pointermove", onMove)
target.addEventListener("pointerenter", onEnter)
target.addEventListener("pointerleave", onLeave)
/*
* Hover in too fast and pointerenter fires before these listeners
* attach, so seed hover from the live :hover state instead.
*/
if (target.matches(":hover")) pointer.hover = 1
return () => {
target.removeEventListener("pointermove", onMove)
target.removeEventListener("pointerenter", onEnter)
target.removeEventListener("pointerleave", onLeave)
}
}, [webglEnabled, pointer])
return (
<>
<video
ref={el}
src={src}
className={className}
style={webglEnabled ? { ...style, opacity: 0 } : style}
autoPlay={autoPlay}
muted={muted}
loop={loop}
playsInline={playsInline}
{...rest}
/>
{webglEnabled && (
<webglTeleport.In>
<Plane
el={el}
segments={segments}
material={material}
pointer={pointer}
uvFit={uvFit}
zIndex={zIndex}
autoReflow={autoReflow}
/>
</webglTeleport.In>
)}
</>
)
}
"use client"
import type { LenisOptions } from "lenis"
import { type LenisRef, ReactLenis } from "lenis/react"
import { cancelFrame, type FrameData, frame } from "motion"
import { type ReactNode, useEffect, useRef } from "react"
type SmoothScrollProps = {
children: ReactNode
options?: LenisOptions
}
/**
* Smooth scroll for the whole page (for now).
*
* Motion is the clock: scroll, animations, and WebGL usually each run on
* their own loop, and can fall out of sync. This provider runs them on a
* single loop, in a fixed order, so they always move together.
*/
export function SmoothScroll({ children, options }: SmoothScrollProps) {
const lenisRef = useRef<LenisRef>(null)
useEffect(() => {
function update(data: FrameData) {
lenisRef.current?.lenis?.raf(data.timestamp)
}
frame.update(update, true)
return () => cancelFrame(update)
}, [])
return (
<ReactLenis root ref={lenisRef} options={{ syncTouch: true, ...options, autoRaf: false }}>
{children}
</ReactLenis>
)
}
## Integrate the <CurveMedia /> component from Atelier UI
You are helping integrate an open-source React component into an existing application.
### Component: CurveMedia
### Description: An SEO-friendly WebGL image or video with scroll-velocity driven bend and chromatic aberration.
### Dependencies: three, @react-three/fiber, @react-three/drei, lenis, motion, @react-three/postprocessing, postprocessing
---
### Usage Example
Add the `WebglProvider` once at the root of your app. See the [installation guide](https://atelier-ui.com/docs/getting-started/installation) for details.
```tsx title="Root layout"
import { WebglProvider } from "@/components/webgl-provider";
export default function RootLayout({ children }) {
return <WebglProvider>{children}</WebglProvider>;
}
```
Then use it anywhere in your app:
```tsx
<CurveMedia
type="image"
src="https://picsum.photos/seed/atelier-1/1200/800"
alt=""
className="w-full h-auto"
/>
```
Works best with smooth scrolling. You can add [Smooth Scroll](https://atelier-ui.com/docs/components/primitive/smooth-scroll) at the root for that:
```tsx title="Root layout"
import { SmoothScroll } from "@/components/smooth-scroll";
export default function RootLayout({ children }) {
return <SmoothScroll>{children}</SmoothScroll>;
}
```
---
### Props
| Name | Type | Default | Description |
| ------------ | -------------------- | --------- | ------------------------------------------------------------------------------- |
| `type` | `"image" \| "video"` | `"image"` | Render the bend on a still image or a video. |
| `src` | `string` | — | Media source. |
| `alt` | `string` | — | Image alt text. Required (and only used) when `type` is `"image"`. |
| `amplitude` | `number` | `0.03` | Maximum bend intensity, applied to the smoothed scroll velocity. |
| `aberration` | `number` | `0.003` | RGB channel split along the scroll axis, scaled by velocity. |
| `smoothing` | `number` | `6` | Exponential smoothing for the velocity (higher = snappier, lower = floatier). |
| `segments` | `number` | `32` | Plane subdivision count. Higher = smoother curve, more vertices. |
| `webglEnabled` | `boolean` | `true` | When false, the WebGL plane is skipped and the plain element is rendered as-is. |
It also forwards `<img>` attributes to [WebGL Image](https://atelier-ui.com/docs/components/primitive/webgl-image) and `<video>` attributes to [WebGL Video](https://atelier-ui.com/docs/components/primitive/webgl-video) depending on `type`.
---
### Full Component Source
#### src/components/curve-media/curve-media.tsx
```tsx
import { shaderMaterial } from "@react-three/drei"
import { extend, type ThreeElement, useFrame } from "@react-three/fiber"
import { useEffect, useRef } from "react"
import { MathUtils, Texture } from "three"
import { WebglImage } from "../webgl-image/webgl-image"
import { WebglVideo } from "../webgl-video/webgl-video"
declare module "@react-three/fiber" {
interface ThreeElements {
curveMediaMat: ThreeElement<typeof CurveMediaMat>
}
}
const vertexShader = /* glsl */ `
precision highp float;
varying vec2 vUv;
uniform float uVelocity;
uniform float uAmplitude;
const float PI = 3.14159265;
void main() {
vUv = uv;
vec3 pos = position;
float bend = sin(uv.x * PI);
pos.y += bend * uVelocity * uAmplitude;
gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);
}
`
const fragmentShader = /* glsl */ `
precision highp float;
varying vec2 vUv;
uniform sampler2D uMap;
uniform float uVelocity;
uniform float uAberration;
void main() {
float shift = uVelocity * uAberration;
vec2 offset = vec2(0.0, shift);
float r = texture2D(uMap, vUv + offset).r;
float g = texture2D(uMap, vUv).g;
float b = texture2D(uMap, vUv - offset).b;
gl_FragColor = vec4(r, g, b, 1.0);
}
`
const CurveMediaMat = shaderMaterial(
{
uMap: new Texture(),
uVelocity: 0,
uAmplitude: 0,
uAberration: 0,
},
vertexShader,
fragmentShader,
)
extend({ CurveMediaMat })
export type CurveEffectProps = {
amplitude?: number
aberration?: number
smoothing?: number
segments?: number
webglEnabled?: boolean
}
type CurveMediaMaterialProps = {
map: Texture
} & Required<Pick<CurveEffectProps, "amplitude" | "aberration" | "smoothing">>
type CurveMediaImageProps = CurveEffectProps & {
type?: "image"
src: string
alt: string
} & Omit<React.ComponentPropsWithoutRef<"img">, "src" | "alt">
type CurveMediaVideoProps = CurveEffectProps & {
type: "video"
src: string
} & Omit<React.ComponentPropsWithoutRef<"video">, "src">
export type CurveMediaProps = CurveMediaImageProps | CurveMediaVideoProps
function CurveMediaMaterial({ map, amplitude, aberration, smoothing }: CurveMediaMaterialProps) {
const ref = useRef<InstanceType<typeof CurveMediaMat>>(null)
const lastScrollY = useRef(0)
const velocity = useRef(0)
useEffect(() => {
lastScrollY.current = window.scrollY
}, [])
useFrame((_, delta) => {
const material = ref.current
if (!material) return
const current = window.scrollY
const instantDelta = current - lastScrollY.current
lastScrollY.current = current
if (delta === 0) return
const target = instantDelta / delta / window.innerHeight
velocity.current = MathUtils.damp(velocity.current, target, smoothing, delta)
material.uVelocity = velocity.current
})
return (
<curveMediaMat
ref={ref}
key={CurveMediaMat.key}
uMap={map}
uAmplitude={amplitude}
uAberration={aberration}
transparent
/>
)
}
export function CurveMedia(props: CurveMediaProps) {
const {
amplitude = 0.03,
aberration = 0.003,
smoothing = 6,
segments = 32,
webglEnabled = true,
...rest
} = props
// Scroll-velocity is read from the window each frame, so the same material
// runs on both the image and video primitives (they share the WebGL plane contract).
const material = (map: Texture) => (
<CurveMediaMaterial
map={map}
amplitude={amplitude}
aberration={aberration}
smoothing={smoothing}
/>
)
if (rest.type === "video") {
const { type: _type, ...videoProps } = rest
return (
<WebglVideo
segments={segments}
webglEnabled={webglEnabled}
material={material}
{...videoProps}
/>
)
}
const { type: _type, ...imageProps } = rest
return (
<WebglImage
segments={segments}
webglEnabled={webglEnabled}
material={material}
{...imageProps}
/>
)
}
```
#### src/components/webgl-image/webgl-image.tsx
```tsx
import { useTexture } from "@react-three/drei"
import { useFrame, useThree } from "@react-three/fiber"
import {
type ComponentRef,
type RefObject,
useEffect,
useLayoutEffect,
useMemo,
useRef,
} from "react"
import { type Mesh, type Texture, Vector2 } from "three"
import { webglTeleport } from "../webgl-portal/webgl-portal"
export type Pointer = {
uv: Vector2
texUv: Vector2
repeat: Vector2
hover: number
}
type WebglImageProps = {
src: string
alt: string
material?: (map: Texture, pointer: Pointer) => React.ReactNode
webglEnabled?: boolean
segments?: number
zIndex?: number
/**
* Re-measures the DOM rect every frame so the plane follows animated parents (motion, parallax).
* Costs one layout read per frame, so only enable it when needed.
*/
autoReflow?: boolean
} & Omit<React.ComponentPropsWithoutRef<"img">, "children" | "src" | "alt">
type PlaneProps = {
el: RefObject<HTMLImageElement | null>
src: string
segments: number
material?: (map: Texture, pointer: Pointer) => React.ReactNode
pointer: Pointer
uvFit: RefObject<{ x: number; y: number }>
zIndex: number
autoReflow: boolean
}
function Plane({ el, src, segments, material, pointer, uvFit, zIndex, autoReflow }: PlaneProps) {
const mesh = useRef<Mesh>(null)
const texture = useTexture(src)
const size = useThree((s) => s.size)
const viewport = useThree((s) => s.viewport)
const fitScale = useRef({ x: 1, y: 1 })
const bounds = useRef({ x: 0, y: 0, width: 0, height: 0 })
useLayoutEffect(() => {
const target = el.current
if (!target) return
const measure = () => {
const m = mesh.current
if (!m) return
/*
* Rect in document coords so viewport position later needs only
* window.scrollX/Y, instead of re-measuring bounds every render.
*/
const rect = target.getBoundingClientRect()
bounds.current.x = rect.left + window.scrollX
bounds.current.y = rect.top + window.scrollY
bounds.current.width = rect.width
bounds.current.height = rect.height
/*
* Replicate CSS object-fit: cover crops via UV repeat/offset,
* contain shrinks the mesh scale (UVs alone can't letterbox).
*/
const image = texture.image as HTMLImageElement
const objectFit = getComputedStyle(target).objectFit
const planeAspect = rect.width / rect.height
const imageAspect = image.width / image.height
let repeatU = 1
let repeatV = 1
fitScale.current.x = 1
fitScale.current.y = 1
if (objectFit === "cover") {
if (planeAspect > imageAspect) {
repeatV = imageAspect / planeAspect
} else {
repeatU = planeAspect / imageAspect
}
} else if (objectFit === "contain") {
if (planeAspect > imageAspect) {
fitScale.current.x = imageAspect / planeAspect
} else {
fitScale.current.y = planeAspect / imageAspect
}
}
const offsetU = (1 - repeatU) / 2
const offsetV = (1 - repeatV) / 2
pointer.repeat.set(repeatU, repeatV)
uvFit.current.x = repeatU / fitScale.current.x
uvFit.current.y = repeatV / fitScale.current.y
const uvAttribute = m.geometry.attributes.uv
for (let iy = 0; iy <= segments; iy++) {
for (let ix = 0; ix <= segments; ix++) {
const idx = iy * (segments + 1) + ix
const u = ix / segments
const v = 1 - iy / segments
uvAttribute.setXY(idx, u * repeatU + offsetU, v * repeatV + offsetV)
}
}
uvAttribute.needsUpdate = true
}
measure()
const ro = new ResizeObserver(measure)
ro.observe(target)
ro.observe(document.body)
return () => ro.disconnect()
}, [el, texture, segments, uvFit, pointer])
useFrame(() => {
const m = mesh.current
if (!m) return
const pxToWorld = viewport.height / size.height
/*
* autoReflow re-reads the rect each frame so the mesh follows parent
* CSS transforms like parallax. One layout read per frame.
*/
if (autoReflow && el.current) {
const rect = el.current.getBoundingClientRect()
m.position.x = (rect.left + rect.width / 2 - size.width / 2) * pxToWorld
m.position.y = -(rect.top + rect.height / 2 - size.height / 2) * pxToWorld
m.scale.x = rect.width * pxToWorld * fitScale.current.x
m.scale.y = rect.height * pxToWorld * fitScale.current.y
return
}
const { x, y, width, height } = bounds.current
m.position.x = (x + width / 2 - window.scrollX - size.width / 2) * pxToWorld
m.position.y = -(y + height / 2 - window.scrollY - size.height / 2) * pxToWorld
m.scale.x = width * pxToWorld * fitScale.current.x
m.scale.y = height * pxToWorld * fitScale.current.y
})
return (
<mesh ref={mesh} renderOrder={zIndex}>
<planeGeometry args={[1, 1, segments, segments]} />
{material ? (
material(texture, pointer)
) : (
<meshBasicMaterial map={texture} transparent />
)}
</mesh>
)
}
export function WebglImage({
src,
alt,
className,
style,
material,
webglEnabled = true,
segments = 1,
zIndex = 0,
autoReflow = false,
...rest
}: WebglImageProps) {
const el = useRef<ComponentRef<"img">>(null)
const uvFit = useRef({ x: 1, y: 1 })
const pointer = useMemo<Pointer>(() => {
return {
uv: new Vector2(0.5, 0.5),
texUv: new Vector2(0.5, 0.5),
repeat: new Vector2(1, 1),
hover: 0,
}
}, [])
useEffect(() => {
if (!webglEnabled) return
const target = el.current
if (!target) return
/*
* Pointer events still fire on the DOM element through opacity:0,
* so the browser tells us when the cursor is over it.
*/
const onMove = (e: PointerEvent) => {
const { width, left, top, height } = target.getBoundingClientRect()
const x = (e.clientX - left) / width
const y = 1 - (e.clientY - top) / height
const fit = uvFit.current
pointer.uv.set(x, y)
pointer.texUv.set(x * fit.x + (1 - fit.x) / 2, y * fit.y + (1 - fit.y) / 2)
}
const onEnter = () => (pointer.hover = 1)
const onLeave = () => (pointer.hover = 0)
target.addEventListener("pointermove", onMove)
target.addEventListener("pointerenter", onEnter)
target.addEventListener("pointerleave", onLeave)
/*
* Hover in too fast and pointerenter fires before these listeners
* attach, so seed hover from the live :hover state instead.
*/
if (target.matches(":hover")) pointer.hover = 1
return () => {
target.removeEventListener("pointermove", onMove)
target.removeEventListener("pointerenter", onEnter)
target.removeEventListener("pointerleave", onLeave)
}
}, [webglEnabled, pointer])
return (
<>
<img
ref={el}
src={src}
alt={alt}
className={className}
style={webglEnabled ? { ...style, opacity: 0 } : style}
{...rest}
/>
{webglEnabled && (
<webglTeleport.In>
<Plane
el={el}
src={src}
segments={segments}
material={material}
pointer={pointer}
uvFit={uvFit}
zIndex={zIndex}
autoReflow={autoReflow}
/>
</webglTeleport.In>
)}
</>
)
}
```
#### src/components/webgl-video/webgl-video.tsx
```tsx
import { useFrame, useThree } from "@react-three/fiber"
import {
type ComponentRef,
type RefObject,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react"
import { type Mesh, SRGBColorSpace, type Texture, Vector2, VideoTexture } from "three"
import { webglTeleport } from "../webgl-portal/webgl-portal"
export type Pointer = {
uv: Vector2
texUv: Vector2
repeat: Vector2
hover: number
}
type WebglVideoProps = {
src: string
material?: (map: Texture, pointer: Pointer) => React.ReactNode
webglEnabled?: boolean
segments?: number
zIndex?: number
/**
* Re-measures the DOM rect every frame so the plane follows animated parents (motion, parallax).
* Costs one layout read per frame, so only enable it when needed.
*/
autoReflow?: boolean
} & Omit<React.ComponentPropsWithoutRef<"video">, "children" | "src">
type PlaneProps = {
el: RefObject<HTMLVideoElement | null>
segments: number
material?: (map: Texture, pointer: Pointer) => React.ReactNode
pointer: Pointer
uvFit: RefObject<{ x: number; y: number }>
zIndex: number
autoReflow: boolean
}
function Plane({ el, segments, material, pointer, uvFit, zIndex, autoReflow }: PlaneProps) {
const mesh = useRef<Mesh>(null)
const [texture, setTexture] = useState<VideoTexture | null>(null)
const size = useThree((state) => state.size)
const viewport = useThree((state) => state.viewport)
const fitScale = useRef({ x: 1, y: 1 })
const bounds = useRef({ x: 0, y: 0, width: 0, height: 0 })
useLayoutEffect(() => {
const video = el.current
if (!video) return
/*
* Build the texture from the DOM <video> itself so a single element
* decodes once. VideoTexture pulls each new frame from it.
*/
const videoTexture = new VideoTexture(video)
videoTexture.colorSpace = SRGBColorSpace
setTexture(videoTexture)
return () => videoTexture.dispose()
}, [el])
useLayoutEffect(() => {
const target = el.current
if (!target || !texture) return
const measure = () => {
const m = mesh.current
if (!m) return
/*
* Rect in document coords so viewport position later needs only
* window.scrollX/Y, instead of re-measuring bounds every render.
*/
const rect = target.getBoundingClientRect()
bounds.current.x = rect.left + window.scrollX
bounds.current.y = rect.top + window.scrollY
bounds.current.width = rect.width
bounds.current.height = rect.height
/*
* Replicate CSS object-fit: cover crops via UV repeat/offset,
* contain shrinks the mesh scale (UVs alone can't letterbox).
*/
const video = texture.image as HTMLVideoElement
const objectFit = getComputedStyle(target).objectFit
const planeAspect = rect.width / rect.height
const videoAspect = video.videoWidth / video.videoHeight
let repeatU = 1
let repeatV = 1
fitScale.current.x = 1
fitScale.current.y = 1
/* videoWidth/Height are 0 until metadata loads, so skip cropping until then. */
if (video.videoWidth > 0) {
if (objectFit === "cover") {
if (planeAspect > videoAspect) {
repeatV = videoAspect / planeAspect
} else {
repeatU = planeAspect / videoAspect
}
} else if (objectFit === "contain") {
if (planeAspect > videoAspect) {
fitScale.current.x = videoAspect / planeAspect
} else {
fitScale.current.y = planeAspect / videoAspect
}
}
}
const offsetU = (1 - repeatU) / 2
const offsetV = (1 - repeatV) / 2
pointer.repeat.set(repeatU, repeatV)
uvFit.current.x = repeatU / fitScale.current.x
uvFit.current.y = repeatV / fitScale.current.y
const uvAttribute = m.geometry.attributes.uv
for (let iy = 0; iy <= segments; iy++) {
for (let ix = 0; ix <= segments; ix++) {
const idx = iy * (segments + 1) + ix
const u = ix / segments
const v = 1 - iy / segments
uvAttribute.setXY(idx, u * repeatU + offsetU, v * repeatV + offsetV)
}
}
uvAttribute.needsUpdate = true
}
measure()
/* Re-measure once the video reports its intrinsic size. */
target.addEventListener("loadedmetadata", measure)
target.addEventListener("resize", measure)
const ro = new ResizeObserver(measure)
ro.observe(target)
ro.observe(document.body)
return () => {
ro.disconnect()
target.removeEventListener("loadedmetadata", measure)
target.removeEventListener("resize", measure)
}
}, [el, texture, segments, uvFit, pointer])
useFrame(() => {
const m = mesh.current
if (!m) return
/* Browsers without requestVideoFrameCallback need an explicit pull. */
texture?.update()
const pxToWorld = viewport.height / size.height
/*
* autoReflow re-reads the rect each frame so the mesh follows parent
* CSS transforms like parallax. One layout read per frame.
*/
if (autoReflow && el.current) {
const rect = el.current.getBoundingClientRect()
m.position.x = (rect.left + rect.width / 2 - size.width / 2) * pxToWorld
m.position.y = -(rect.top + rect.height / 2 - size.height / 2) * pxToWorld
m.scale.x = rect.width * pxToWorld * fitScale.current.x
m.scale.y = rect.height * pxToWorld * fitScale.current.y
return
}
const { x, y, width, height } = bounds.current
m.position.x = (x + width / 2 - window.scrollX - size.width / 2) * pxToWorld
m.position.y = -(y + height / 2 - window.scrollY - size.height / 2) * pxToWorld
m.scale.x = width * pxToWorld * fitScale.current.x
m.scale.y = height * pxToWorld * fitScale.current.y
})
if (!texture) return null
return (
<mesh ref={mesh} renderOrder={zIndex}>
<planeGeometry args={[1, 1, segments, segments]} />
{material ? (
material(texture, pointer)
) : (
<meshBasicMaterial map={texture} transparent />
)}
</mesh>
)
}
export function WebglVideo({
src,
className,
style,
material,
webglEnabled = true,
segments = 1,
zIndex = 0,
autoReflow = false,
autoPlay = true,
muted = true,
loop = true,
playsInline = true,
...rest
}: WebglVideoProps) {
const el = useRef<ComponentRef<"video">>(null)
const uvFit = useRef({ x: 1, y: 1 })
const pointer = useMemo<Pointer>(() => {
return {
uv: new Vector2(0.5, 0.5),
texUv: new Vector2(0.5, 0.5),
repeat: new Vector2(1, 1),
hover: 0,
}
}, [])
useEffect(() => {
if (!webglEnabled) return
const target = el.current
if (!target) return
/*
* Pointer events still fire on the DOM element through opacity:0,
* so the browser tells us when the cursor is over it.
*/
const onMove = (event: PointerEvent) => {
const { width, left, top, height } = target.getBoundingClientRect()
const x = (event.clientX - left) / width
const y = 1 - (event.clientY - top) / height
const fit = uvFit.current
pointer.uv.set(x, y)
pointer.texUv.set(x * fit.x + (1 - fit.x) / 2, y * fit.y + (1 - fit.y) / 2)
}
const onEnter = () => (pointer.hover = 1)
const onLeave = () => (pointer.hover = 0)
target.addEventListener("pointermove", onMove)
target.addEventListener("pointerenter", onEnter)
target.addEventListener("pointerleave", onLeave)
/*
* Hover in too fast and pointerenter fires before these listeners
* attach, so seed hover from the live :hover state instead.
*/
if (target.matches(":hover")) pointer.hover = 1
return () => {
target.removeEventListener("pointermove", onMove)
target.removeEventListener("pointerenter", onEnter)
target.removeEventListener("pointerleave", onLeave)
}
}, [webglEnabled, pointer])
return (
<>
<video
ref={el}
src={src}
className={className}
style={webglEnabled ? { ...style, opacity: 0 } : style}
autoPlay={autoPlay}
muted={muted}
loop={loop}
playsInline={playsInline}
{...rest}
/>
{webglEnabled && (
<webglTeleport.In>
<Plane
el={el}
segments={segments}
material={material}
pointer={pointer}
uvFit={uvFit}
zIndex={zIndex}
autoReflow={autoReflow}
/>
</webglTeleport.In>
)}
</>
)
}
```
#### src/components/smooth-scroll/smooth-scroll.tsx
```tsx
"use client"
import type { LenisOptions } from "lenis"
import { type LenisRef, ReactLenis } from "lenis/react"
import { cancelFrame, type FrameData, frame } from "motion"
import { type ReactNode, useEffect, useRef } from "react"
type SmoothScrollProps = {
children: ReactNode
options?: LenisOptions
}
/**
* Smooth scroll for the whole page (for now).
*
* Motion is the clock: scroll, animations, and WebGL usually each run on
* their own loop, and can fall out of sync. This provider runs them on a
* single loop, in a fixed order, so they always move together.
*/
export function SmoothScroll({ children, options }: SmoothScrollProps) {
const lenisRef = useRef<LenisRef>(null)
useEffect(() => {
function update(data: FrameData) {
lenisRef.current?.lenis?.raf(data.timestamp)
}
frame.update(update, true)
return () => cancelFrame(update)
}, [])
return (
<ReactLenis root ref={lenisRef} options={{ syncTouch: true, ...options, autoRaf: false }}>
{children}
</ReactLenis>
)
}
```
#### src/components/webgl-portal/webgl-portal.tsx
```tsx
import {
type ReactNode,
Suspense,
useEffect,
useId,
useLayoutEffect,
useSyncExternalStore,
} from "react"
const useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect
// Minimal teleport: <In> registers children in an external store,
// <Out> renders them — bridges across the Canvas React root the same
function WebglTeleport() {
const items = new Map<string, ReactNode>()
const listeners = new Set<() => void>()
let snapshot: [string, ReactNode][] = []
const emit = () => {
snapshot = Array.from(items.entries())
for (const listener of listeners) {
listener()
}
}
const subscribe = (listener: () => void) => {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
const getSnapshot = () => snapshot
function useItems() {
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
}
return {
In({ children }: { children: ReactNode }) {
const id = useId()
useIsoLayoutEffect(() => {
items.set(id, children)
emit()
return () => {
items.delete(id)
emit()
}
}, [id, children])
return null
},
useItems,
Out() {
const list = useItems()
return (
<>
{list.map(([id, node]) => (
<Suspense key={id} fallback={null}>
{node}
</Suspense>
))}
</>
)
},
}
}
const webglTeleport = WebglTeleport()
const effectTeleport = WebglTeleport()
export function WebglPortal() {
return <webglTeleport.Out />
}
export { effectTeleport, webglTeleport }
```
#### src/components/webgl-provider/webgl-provider.tsx
```tsx
"use client"
import { advance, Canvas, type CanvasProps, useStore, useThree } from "@react-three/fiber"
import { EffectComposer } from "@react-three/postprocessing"
import { cancelFrame, type FrameData, frame } from "motion"
import { type ComponentRef, type ReactNode, useEffect, useRef, useState } from "react"
import type { Camera, Scene } from "three"
import { effectTeleport, WebglPortal } from "../webgl-portal/webgl-portal"
type WebglProviderProps = Omit<CanvasProps, "children" | "eventSource"> & {
children: ReactNode
className?: string
contained?: boolean
}
type WebglReadyOptions = {
scene?: Scene
camera?: Camera
enabled?: boolean
onReady?: () => void
}
export function useWebglReady({ scene, camera, enabled = true, onReady }: WebglReadyOptions = {}) {
const [ready, setReady] = useState(false)
const gl = useThree((state) => state.gl)
const defaultScene = useThree((state) => state.scene)
const defaultCamera = useThree((state) => state.camera)
const onReadyRef = useRef(onReady)
onReadyRef.current = onReady
const targetScene = scene ?? defaultScene
const targetCamera = camera ?? defaultCamera
useEffect(() => {
if (!enabled) return
let active = true
gl.compileAsync(targetScene, targetCamera).then(() => {
if (!active) return
requestAnimationFrame(() => {
if (!active) return
setReady(true)
onReadyRef.current?.()
})
})
return () => {
active = false
}
}, [gl, targetScene, targetCamera, enabled])
return ready
}
// Renders in Motion's `postRender` phase, after Lenis and Motion have
// updated. One shared driver serves every mounted provider.
type CanvasStore = ReturnType<typeof useStore>
const canvasStores = new Set<CanvasStore>()
let clockStart: number | null = null
function tick(data: FrameData) {
if (clockStart === null) clockStart = data.timestamp
// frameloop="never" expects the elapsed clock time in seconds.
const elapsed = (data.timestamp - clockStart) / 1000
let runGlobalEffects = true
for (const store of canvasStores) {
const state = store.getState()
if (state.internal.active) {
advance(elapsed, runGlobalEffects, state)
runGlobalEffects = false
}
}
}
function MotionFrameloop() {
const store = useStore()
useEffect(() => {
canvasStores.add(store)
if (canvasStores.size === 1) frame.postRender(tick, true)
return () => {
canvasStores.delete(store)
if (canvasStores.size === 0) cancelFrame(tick)
}
}, [store])
return null
}
function Effects() {
const effects = effectTeleport.useItems()
if (effects.length === 0) return null
return (
<EffectComposer key={effects.length}>
<effectTeleport.Out />
</EffectComposer>
)
}
export function WebglProvider({
children,
className,
style,
contained = false,
...canvasProps
}: WebglProviderProps) {
const [eventSource, setEventSource] = useState<ComponentRef<"div"> | null>(null)
return (
<div
ref={setEventSource}
className={className}
style={contained ? { position: "relative" } : { display: "contents" }}
>
<Canvas
eventPrefix="client"
dpr={[1, 1.5]}
{...canvasProps}
frameloop="never"
eventSource={eventSource ?? undefined}
style={{
position: contained ? "absolute" : "fixed",
inset: 0,
pointerEvents: "none",
...style,
}}
>
<MotionFrameloop />
<WebglPortal />
<Effects />
</Canvas>
{children}
</div>
)
}
```
---
### Integration Instructions
1. If you can execute shell commands, run `npx atelier-ui add curve-media` from the project root instead of steps 2-3 (it installs everything automatically).
2. Install the npm dependencies: three, @react-three/fiber, @react-three/drei, lenis, motion, @react-three/postprocessing, postprocessing.
3. Copy each file from the component source above to the exact path shown.
4. Add the `WebglProvider` once at the app root as shown in the usage example (skip if one is already there - never add a second one).
5. Render `<CurveMedia />` where it belongs in the app, using the usage example as a starting point and the props table to adjust it.
Full documentation: https://atelier-ui.com/en/docs/components/scroll/curve-mediaAdd the WebglProvider once at the root of your app. See the installation guide for details.
import { WebglProvider } from "@/components/webgl-provider";
export default function RootLayout({ children }) {
return <WebglProvider>{children}</WebglProvider>;
}Then use it anywhere in your app:
<CurveMedia
type="image"
src="https://picsum.photos/seed/atelier-1/1200/800"
alt=""
className="w-full h-auto"
/>Works best with smooth scrolling. You can add Smooth Scroll at the root for that:
import { SmoothScroll } from "@/components/smooth-scroll";
export default function RootLayout({ children }) {
return <SmoothScroll>{children}</SmoothScroll>;
}| Name | Type | Default | Description |
|---|---|---|---|
type | "image" | "video" | "image" | Render the bend on a still image or a video. |
src | string | — | Media source. |
alt | string | — | Image alt text. Required (and only used) when type is "image". |
amplitude | number | 0.03 | Maximum bend intensity, applied to the smoothed scroll velocity. |
aberration | number | 0.003 | RGB channel split along the scroll axis, scaled by velocity. |
smoothing | number | 6 | Exponential smoothing for the velocity (higher = snappier, lower = floatier). |
segments | number | 32 | Plane subdivision count. Higher = smoother curve, more vertices. |
webglEnabled | boolean | true | When false, the WebGL plane is skipped and the plain element is rendered as-is. |
It also forwards <img> attributes to WebGL Image and <video> attributes to WebGL Video depending on type.
React Three Fiber
React renderer for Three.js used for the WebGL plane.
Lenis
Smooth scroll library used in the demo.