diff --git a/apps/www/content/docs/components/retro-grid.mdx b/apps/www/content/docs/components/retro-grid.mdx index 1679da80c..c43504f6b 100644 --- a/apps/www/content/docs/components/retro-grid.mdx +++ b/apps/www/content/docs/components/retro-grid.mdx @@ -35,24 +35,7 @@ npx shadcn@latest add @magicui/retro-grid Update the import paths to match your project setup. -Add the required CSS animations - -Add the following animations to your global CSS file. - -```css title="app/globals.css" showLineNumbers {2, 4-11} -@theme inline { - --animate-grid: grid 15s linear infinite; - - @keyframes grid { - 0% { - transform: translateY(-50%); - } - 100% { - transform: translateY(0); - } - } -} -``` +No additional CSS is required. diff --git a/apps/www/public/llms-full.txt b/apps/www/public/llms-full.txt index a0f339e2b..20fb1116c 100644 --- a/apps/www/public/llms-full.txt +++ b/apps/www/public/llms-full.txt @@ -12827,9 +12827,257 @@ Title: Retro Grid Description: An animated scrolling retro grid effect --- file: magicui/retro-grid.tsx --- +"use client" + +import { useEffect, useRef, useState } from "react" +import type { CSSProperties, HTMLAttributes } from "react" + import { cn } from "@/lib/utils" -interface RetroGridProps extends React.HTMLAttributes { +const ANIMATION_DURATION_SECONDS = 15 +const GRID_HEIGHT_RATIO = 3 +const GRID_LINE_ALIGNMENT_OFFSET_PX = 0.5 +const GRID_LINE_ANTIALIAS_MULTIPLIER = 0.9 +const GRID_LINE_WIDTH_PX = 0.92 +const GRID_START_OFFSET_RATIO = -0.5 +const GRID_WIDTH_RATIO = 6 +const GRID_X_OFFSET_RATIO = -2 +const MAX_ANGLE = 89 +const MAX_DEVICE_PIXEL_RATIO = 2 +const MIN_ANGLE = 1 +const PERSPECTIVE_PX = 200 +const FALLBACK_ANIMATION_NAME = "retro-grid-fallback-scroll" +const FALLBACK_STYLES = ` +@keyframes ${FALLBACK_ANIMATION_NAME} { + from { + transform: translateY(-50%); + } + + to { + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + [data-retro-grid-scroll="true"] { + animation: none !important; + transform: translateY(-50%) !important; + } +} +` + +const VERTEX_SHADER_SOURCE = ` +attribute vec2 a_position; + +void main() { + gl_Position = vec4(a_position, 0.0, 1.0); +} +` + +const FRAGMENT_SHADER_SOURCE = ` +#extension GL_OES_standard_derivatives : enable +precision highp float; + +uniform vec2 u_container_size; +uniform vec2 u_viewport_size; +uniform vec4 u_line_color; +uniform float u_angle; +uniform float u_cell_size; +uniform float u_device_pixel_ratio; +uniform float u_time; + +const float animationDurationSeconds = ${ANIMATION_DURATION_SECONDS.toFixed(1)}; +const float gridHeightRatio = ${GRID_HEIGHT_RATIO.toFixed(1)}; +const float gridStartOffsetRatio = ${GRID_START_OFFSET_RATIO.toFixed(1)}; +const float gridWidthRatio = ${GRID_WIDTH_RATIO.toFixed(1)}; +const float gridXOffsetRatio = ${GRID_X_OFFSET_RATIO.toFixed(1)}; +const float gridLineAlignmentOffsetPx = ${GRID_LINE_ALIGNMENT_OFFSET_PX.toFixed(1)}; +const float gridLineAntialiasMultiplier = ${GRID_LINE_ANTIALIAS_MULTIPLIER.toFixed(1)}; +const float horizontalLodLevelOneEndPx = 5.6; +const float horizontalLodLevelOneStartPx = 2.8; +const float horizontalLodLevelTwoEndPx = 3.0; +const float horizontalLodLevelTwoStartPx = 1.4; +const float horizontalCompressionEndPx = 2.8; +const float horizontalCompressionStartPx = 1.2; +const float lineWidthPx = ${GRID_LINE_WIDTH_PX.toFixed(2)}; +const float perspectivePx = ${PERSPECTIVE_PX.toFixed(1)}; +const float gridTravelRatio = 0.5; +const float verticalCompressionEndPx = 2.6; +const float verticalCompressionStartPx = 1.0; +const float verticalEdgeCompressionEnd = 0.95; +const float verticalEdgeCompressionStart = 0.45; +const float verticalLodLevelEnd = 0.64; +const float verticalLodLevelStart = 0.22; +const float verticalTopCompressionEndCells = 6.0; +const float verticalTopCompressionStartCells = 2.0; + +float renderGridLine( + float wrappedCoord, + float antiAliasWidth, + float softnessBoost +) { + return 1.0 - smoothstep( + lineWidthPx, + lineWidthPx + (antiAliasWidth * (1.5 + softnessBoost)), + wrappedCoord + ); +} + +void main() { + float angle = radians(clamp(u_angle, 1.0, 89.0)); + float sinAngle = sin(angle); + float cosAngle = cos(angle); + vec2 screen = vec2( + (gl_FragCoord.x / u_device_pixel_ratio) - (u_container_size.x * 0.5), + (u_container_size.y * 0.5) - (gl_FragCoord.y / u_device_pixel_ratio) + ); + + vec3 rayOrigin = vec3(0.0, 0.0, perspectivePx); + vec3 rayDirection = normalize(vec3(screen, -perspectivePx)); + vec3 planeXAxis = vec3(1.0, 0.0, 0.0); + vec3 planeYAxis = vec3(0.0, cosAngle, sinAngle); + vec3 planeNormal = normalize(cross(planeXAxis, planeYAxis)); + float denominator = dot(rayDirection, planeNormal); + + if (abs(denominator) < 0.0001) { + discard; + } + + float distanceToPlane = dot(-rayOrigin, planeNormal) / denominator; + + if (distanceToPlane <= 0.0) { + discard; + } + + vec3 hitPoint = rayOrigin + (rayDirection * distanceToPlane); + float localX = hitPoint.x; + float localY = dot(hitPoint, planeYAxis); + float gridWidth = u_viewport_size.x * gridWidthRatio; + float gridHeight = u_viewport_size.y * gridHeightRatio; + float gridScrollSpeed = (gridHeight * gridTravelRatio) / animationDurationSeconds; + float patternOffsetY = u_time * gridScrollSpeed; + float gridLeft = (-0.5 * u_container_size.x) + (gridXOffsetRatio * u_container_size.x); + float gridTop = (-0.5 * u_container_size.y) + (gridStartOffsetRatio * gridHeight); + vec2 planePosition = vec2(localX - gridLeft, localY - gridTop); + + if ( + planePosition.x < 0.0 || + planePosition.y < 0.0 || + planePosition.x > gridWidth || + planePosition.y > gridHeight + ) { + discard; + } + + vec2 patternPosition = vec2(planePosition.x, planePosition.y - patternOffsetY); + vec2 wrapped = mod( + patternPosition + vec2(gridLineAlignmentOffsetPx), + u_cell_size + ); + vec2 patternDerivative = max(fwidth(patternPosition), vec2(0.0001)); + vec2 antiAliasWidth = patternDerivative * gridLineAntialiasMultiplier; + float horizontalCellSpanPx = u_cell_size / patternDerivative.y; + float horizontalCompression = 1.0 - smoothstep( + horizontalCompressionStartPx, + horizontalCompressionEndPx, + horizontalCellSpanPx + ); + float verticalCellSpanPx = u_cell_size / patternDerivative.x; + float sideDistance = abs((planePosition.x / gridWidth) * 2.0 - 1.0); + float verticalEdgeCompression = smoothstep( + verticalEdgeCompressionStart, + verticalEdgeCompressionEnd, + sideDistance + ); + float verticalTopCompression = 1.0 - smoothstep( + u_cell_size * verticalTopCompressionStartCells, + u_cell_size * verticalTopCompressionEndCells, + planePosition.y + ); + float verticalCompression = + (1.0 - smoothstep( + verticalCompressionStartPx, + verticalCompressionEndPx, + verticalCellSpanPx + )) * verticalEdgeCompression * verticalTopCompression; + float horizontalSoftnessBoost = 1.0 + (horizontalCompression * 3.0); + float verticalSoftnessBoost = 1.0 + (verticalCompression * 3.5); + float verticalLod = smoothstep( + verticalLodLevelStart, + verticalLodLevelEnd, + verticalCompression + ); + float verticalLineFine = renderGridLine( + wrapped.x, + antiAliasWidth.x, + verticalSoftnessBoost + ); + float verticalWrappedLod = mod( + patternPosition.x + gridLineAlignmentOffsetPx, + u_cell_size * 2.0 + ); + float verticalLineCoarse = renderGridLine( + verticalWrappedLod, + antiAliasWidth.x, + verticalSoftnessBoost + verticalLod + ); + float verticalLine = max( + verticalLineFine * (1.0 - verticalLod), + verticalLineCoarse * verticalLod + ); + float horizontalLodLevelOne = 1.0 - smoothstep( + horizontalLodLevelOneStartPx, + horizontalLodLevelOneEndPx, + horizontalCellSpanPx + ); + float horizontalLodLevelTwo = 1.0 - smoothstep( + horizontalLodLevelTwoStartPx, + horizontalLodLevelTwoEndPx, + horizontalCellSpanPx + ); + float horizontalLineFine = renderGridLine( + wrapped.y, + antiAliasWidth.y, + horizontalSoftnessBoost + ); + float horizontalWrappedLodOne = mod( + patternPosition.y + gridLineAlignmentOffsetPx, + u_cell_size * 2.0 + ); + float horizontalWrappedLodTwo = mod( + patternPosition.y + gridLineAlignmentOffsetPx, + u_cell_size * 4.0 + ); + float horizontalLineCoarse = renderGridLine( + horizontalWrappedLodOne, + antiAliasWidth.y, + horizontalSoftnessBoost + horizontalLodLevelOne + ); + float horizontalLineExtraCoarse = renderGridLine( + horizontalWrappedLodTwo, + antiAliasWidth.y, + horizontalSoftnessBoost + horizontalLodLevelOne + horizontalLodLevelTwo + ); + float horizontalLineReduced = max( + horizontalLineFine * (1.0 - horizontalLodLevelOne), + horizontalLineCoarse * horizontalLodLevelOne + ); + float horizontalLine = max( + horizontalLineReduced * (1.0 - horizontalLodLevelTwo), + horizontalLineExtraCoarse * horizontalLodLevelTwo + ); + float line = max(verticalLine, horizontalLine); + + if (line <= 0.001) { + discard; + } + + float alpha = u_line_color.a * line; + gl_FragColor = vec4(u_line_color.rgb * alpha, alpha); +} +` + +interface RetroGridProps extends HTMLAttributes { /** * Additional CSS classes to apply to the grid container */ @@ -12861,6 +13109,193 @@ interface RetroGridProps extends React.HTMLAttributes { darkLineColor?: string } +interface ProgramInfo { + attributeLocation: number + program: WebGLProgram + uniforms: { + angle: WebGLUniformLocation + cellSize: WebGLUniformLocation + containerSize: WebGLUniformLocation + devicePixelRatio: WebGLUniformLocation + lineColor: WebGLUniformLocation + time: WebGLUniformLocation + viewportSize: WebGLUniformLocation + } +} + +let colorResolveContext: CanvasRenderingContext2D | null | undefined + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max) +} + +function createShader(gl: WebGLRenderingContext, type: number, source: string) { + const shader = gl.createShader(type) + + if (!shader) { + return null + } + + gl.shaderSource(shader, source) + gl.compileShader(shader) + + if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + return shader + } + + gl.deleteShader(shader) + return null +} + +function createProgram(gl: WebGLRenderingContext) { + const vertexShader = createShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER_SOURCE) + const fragmentShader = createShader( + gl, + gl.FRAGMENT_SHADER, + FRAGMENT_SHADER_SOURCE + ) + + if (!vertexShader || !fragmentShader) { + return null + } + + const program = gl.createProgram() + + if (!program) { + gl.deleteShader(vertexShader) + gl.deleteShader(fragmentShader) + return null + } + + gl.attachShader(program, vertexShader) + gl.attachShader(program, fragmentShader) + gl.linkProgram(program) + gl.deleteShader(vertexShader) + gl.deleteShader(fragmentShader) + + if (gl.getProgramParameter(program, gl.LINK_STATUS)) { + return program + } + + gl.deleteProgram(program) + return null +} + +function getProgramInfo( + gl: WebGLRenderingContext, + program: WebGLProgram +): ProgramInfo | null { + const attributeLocation = gl.getAttribLocation(program, "a_position") + const angle = gl.getUniformLocation(program, "u_angle") + const cellSize = gl.getUniformLocation(program, "u_cell_size") + const containerSize = gl.getUniformLocation(program, "u_container_size") + const devicePixelRatio = gl.getUniformLocation( + program, + "u_device_pixel_ratio" + ) + const lineColor = gl.getUniformLocation(program, "u_line_color") + const time = gl.getUniformLocation(program, "u_time") + const viewportSize = gl.getUniformLocation(program, "u_viewport_size") + + if ( + attributeLocation < 0 || + !angle || + !cellSize || + !containerSize || + !devicePixelRatio || + !lineColor || + !time || + !viewportSize + ) { + return null + } + + return { + attributeLocation, + program, + uniforms: { + angle, + cellSize, + containerSize, + devicePixelRatio, + lineColor, + time, + viewportSize, + }, + } +} + +function isDarkMode(colorScheme: MediaQueryList) { + const root = document.documentElement + + if (root.classList.contains("dark")) { + return true + } + + if (root.classList.contains("light")) { + return false + } + + return colorScheme.matches +} + +function getColorResolveContext() { + if (colorResolveContext !== undefined) { + return colorResolveContext + } + + const canvas = document.createElement("canvas") + canvas.width = 1 + canvas.height = 1 + colorResolveContext = canvas.getContext("2d", { + willReadFrequently: true, + }) + + return colorResolveContext +} + +function resolveLineColor(color: string, element: HTMLElement) { + const resolver = document.createElement("span") + resolver.style.color = color + resolver.style.opacity = "0" + resolver.style.pointerEvents = "none" + resolver.style.position = "absolute" + element.appendChild(resolver) + + const resolvedColor = getComputedStyle(resolver).color + resolver.remove() + const context = getColorResolveContext() + + if (!context) { + return new Float32Array([0.5, 0.5, 0.5, 1]) + } + + context.clearRect(0, 0, 1, 1) + context.fillStyle = resolvedColor + context.fillRect(0, 0, 1, 1) + const pixel = context.getImageData(0, 0, 1, 1).data + + return new Float32Array([ + pixel[0] / 255, + pixel[1] / 255, + pixel[2] / 255, + pixel[3] / 255, + ]) +} + +function createFallbackGridStyle( + cellSize: number, + lineColor: string +): CSSProperties { + return { + animation: `${FALLBACK_ANIMATION_NAME} ${ANIMATION_DURATION_SECONDS}s linear infinite`, + backgroundImage: `linear-gradient(to right, ${lineColor} 1px, transparent 0), linear-gradient(to bottom, ${lineColor} 1px, transparent 0)`, + backgroundRepeat: "repeat", + backgroundSize: `${cellSize}px ${cellSize}px`, + transform: "translateY(-50%)", + } +} + export function RetroGrid({ className, angle = 65, @@ -12868,30 +13303,392 @@ export function RetroGrid({ opacity = 0.5, lightLineColor = "gray", darkLineColor = "gray", + style, ...props }: RetroGridProps) { + const canvasRef = useRef(null) + const containerRef = useRef(null) + const [isWebGlReady, setIsWebGlReady] = useState(false) + const angleRef = useRef(angle) + const cellSizeRef = useRef(cellSize) + const darkLineColorRef = useRef(darkLineColor) + const lightLineColorRef = useRef(lightLineColor) + const syncSceneRef = useRef<(() => void) | null>(null) + + useEffect(() => { + angleRef.current = angle + cellSizeRef.current = cellSize + darkLineColorRef.current = darkLineColor + lightLineColorRef.current = lightLineColor + syncSceneRef.current?.() + }, [angle, cellSize, darkLineColor, lightLineColor]) + + useEffect(() => { + const canvas = canvasRef.current + const container = containerRef.current + + if (!canvas || !container) { + return + } + + const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)") + const colorScheme = window.matchMedia("(prefers-color-scheme: dark)") + + let animationFrameId: number | null = null + let currentWidth = 0 + let currentHeight = 0 + let currentDevicePixelRatio = 1 + let gl: WebGLRenderingContext | null = null + let isVisible = true + let isContextLost = false + let lineColor = resolveLineColor(lightLineColorRef.current, container) + let positionBuffer: WebGLBuffer | null = null + let programInfo: ProgramInfo | null = null + + const getContext = () => { + const nextGl = canvas.getContext("webgl", { + alpha: true, + antialias: true, + premultipliedAlpha: true, + }) + + if (!nextGl || !nextGl.getExtension("OES_standard_derivatives")) { + return null + } + + return nextGl + } + + const releasePipeline = (shouldDeleteResources: boolean) => { + if (shouldDeleteResources && gl) { + if (positionBuffer) { + gl.deleteBuffer(positionBuffer) + } + + if (programInfo) { + gl.deleteProgram(programInfo.program) + } + } + + positionBuffer = null + programInfo = null + + if (shouldDeleteResources) { + gl = null + } + } + + const initializePipeline = () => { + const nextGl = getContext() + + if (!nextGl) { + releasePipeline(false) + return false + } + + gl = nextGl + releasePipeline(true) + gl = nextGl + + const program = createProgram(nextGl) + + if (!program) { + return false + } + + const nextProgramInfo = getProgramInfo(nextGl, program) + + if (!nextProgramInfo) { + nextGl.deleteProgram(program) + return false + } + + const nextPositionBuffer = nextGl.createBuffer() + + if (!nextPositionBuffer) { + nextGl.deleteProgram(program) + return false + } + + nextGl.bindBuffer(nextGl.ARRAY_BUFFER, nextPositionBuffer) + nextGl.bufferData( + nextGl.ARRAY_BUFFER, + new Float32Array([-1, -1, 3, -1, -1, 3]), + nextGl.STATIC_DRAW + ) + + positionBuffer = nextPositionBuffer + programInfo = nextProgramInfo + + return true + } + + const updateLineColor = () => { + const activeColor = isDarkMode(colorScheme) + ? darkLineColorRef.current + : lightLineColorRef.current + lineColor = resolveLineColor(activeColor, container) + } + + const resizeCanvas = () => { + currentWidth = Math.floor(container.clientWidth) + currentHeight = Math.floor(container.clientHeight) + + if (currentWidth === 0 || currentHeight === 0 || !gl) { + return + } + + currentDevicePixelRatio = Math.min( + window.devicePixelRatio || 1, + MAX_DEVICE_PIXEL_RATIO + ) + + canvas.width = Math.floor(currentWidth * currentDevicePixelRatio) + canvas.height = Math.floor(currentHeight * currentDevicePixelRatio) + canvas.style.width = `${currentWidth}px` + canvas.style.height = `${currentHeight}px` + gl.viewport(0, 0, canvas.width, canvas.height) + } + + const draw = (timestamp: number) => { + if ( + currentWidth === 0 || + currentHeight === 0 || + !gl || + !positionBuffer || + !programInfo || + isContextLost + ) { + return + } + + gl.useProgram(programInfo.program) + gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer) + gl.enableVertexAttribArray(programInfo.attributeLocation) + gl.vertexAttribPointer( + programInfo.attributeLocation, + 2, + gl.FLOAT, + false, + 0, + 0 + ) + gl.clearColor(0, 0, 0, 0) + gl.clear(gl.COLOR_BUFFER_BIT) + gl.uniform1f( + programInfo.uniforms.angle, + clamp(angleRef.current, MIN_ANGLE, MAX_ANGLE) + ) + gl.uniform1f( + programInfo.uniforms.cellSize, + Math.max(cellSizeRef.current, 1) + ) + gl.uniform2f( + programInfo.uniforms.containerSize, + currentWidth, + currentHeight + ) + gl.uniform1f( + programInfo.uniforms.devicePixelRatio, + currentDevicePixelRatio + ) + gl.uniform4fv(programInfo.uniforms.lineColor, lineColor) + gl.uniform1f( + programInfo.uniforms.time, + reducedMotion.matches ? 0 : timestamp / 1000 + ) + gl.uniform2f( + programInfo.uniforms.viewportSize, + window.innerWidth, + window.innerHeight + ) + gl.drawArrays(gl.TRIANGLES, 0, 3) + } + + const stopAnimation = () => { + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId) + animationFrameId = null + } + } + + const frame = (timestamp: number) => { + draw(timestamp) + + if (!reducedMotion.matches && isVisible) { + animationFrameId = requestAnimationFrame(frame) + return + } + + animationFrameId = null + } + + const syncScene = () => { + if (isContextLost) { + stopAnimation() + setIsWebGlReady(false) + return + } + + if (!gl || !positionBuffer || !programInfo) { + if (!initializePipeline()) { + stopAnimation() + setIsWebGlReady(false) + return + } + } + + resizeCanvas() + + if (currentWidth === 0 || currentHeight === 0) { + stopAnimation() + return + } + + updateLineColor() + draw(performance.now()) + setIsWebGlReady(true) + + if (reducedMotion.matches || !isVisible) { + stopAnimation() + return + } + + if (animationFrameId === null) { + animationFrameId = requestAnimationFrame(frame) + } + } + + syncSceneRef.current = syncScene + + const resizeObserver = new ResizeObserver(() => { + syncScene() + }) + resizeObserver.observe(container) + + const handleWindowResize = () => { + syncScene() + } + + const intersectionObserver = new IntersectionObserver(([entry]) => { + isVisible = entry?.isIntersecting ?? false + + if (isVisible) { + syncScene() + return + } + + stopAnimation() + }) + intersectionObserver.observe(container) + + const themeObserver = new MutationObserver(() => { + syncScene() + }) + themeObserver.observe(document.documentElement, { + attributeFilter: ["class"], + attributes: true, + }) + + const handleMotionChange = () => { + syncScene() + } + + const handleColorSchemeChange = () => { + syncScene() + } + + const handleContextLost = (event: Event) => { + event.preventDefault() + isContextLost = true + stopAnimation() + releasePipeline(false) + setIsWebGlReady(false) + } + + const handleContextRestored = () => { + isContextLost = false + syncScene() + } + + reducedMotion.addEventListener("change", handleMotionChange) + colorScheme.addEventListener("change", handleColorSchemeChange) + window.addEventListener("resize", handleWindowResize) + canvas.addEventListener("webglcontextlost", handleContextLost) + canvas.addEventListener("webglcontextrestored", handleContextRestored) + + syncScene() + + return () => { + stopAnimation() + resizeObserver.disconnect() + intersectionObserver.disconnect() + themeObserver.disconnect() + reducedMotion.removeEventListener("change", handleMotionChange) + colorScheme.removeEventListener("change", handleColorSchemeChange) + window.removeEventListener("resize", handleWindowResize) + canvas.removeEventListener("webglcontextlost", handleContextLost) + canvas.removeEventListener("webglcontextrestored", handleContextRestored) + syncSceneRef.current = null + releasePipeline(!isContextLost) + } + }, []) + const gridStyles = { - "--grid-angle": `${angle}deg`, - "--cell-size": `${cellSize}px`, - "--opacity": opacity, - "--light-line": lightLineColor, - "--dark-line": darkLineColor, - } as React.CSSProperties + ...style, + opacity, + } as CSSProperties + const normalizedAngle = clamp(angle, MIN_ANGLE, MAX_ANGLE) + const normalizedCellSize = Math.max(cellSize, 1) + const fallbackProjectionStyles = { + perspective: `${PERSPECTIVE_PX}px`, + } as CSSProperties + const fallbackRotationStyles = { + transform: `rotateX(${normalizedAngle}deg)`, + } as CSSProperties + const lightFallbackGridStyles = createFallbackGridStyle( + normalizedCellSize, + lightLineColor + ) + const darkFallbackGridStyles = createFallbackGridStyle( + normalizedCellSize, + darkLineColor + ) return (
-
-
-
- + + {!isWebGlReady ? ( +
+
+
+
+
+
+ ) : null} +
) diff --git a/apps/www/public/r/registry.json b/apps/www/public/r/registry.json index 724c9df2e..ec7e83ecd 100644 --- a/apps/www/public/r/registry.json +++ b/apps/www/public/r/registry.json @@ -569,22 +569,7 @@ "path": "registry/magicui/retro-grid.tsx", "type": "registry:ui" } - ], - "cssVars": { - "theme": { - "animate-grid": "grid 15s linear infinite" - } - }, - "css": { - "@keyframes grid": { - "0%": { - "transform": "translateY(-50%)" - }, - "100%": { - "transform": "translateY(0)" - } - } - } + ] }, { "name": "animated-list", diff --git a/apps/www/public/r/retro-grid.json b/apps/www/public/r/retro-grid.json index 3e77bcd8e..9ca925691 100644 --- a/apps/www/public/r/retro-grid.json +++ b/apps/www/public/r/retro-grid.json @@ -7,23 +7,8 @@ "files": [ { "path": "registry/magicui/retro-grid.tsx", - "content": "import { cn } from \"@/lib/utils\"\n\ninterface RetroGridProps extends React.HTMLAttributes {\n /**\n * Additional CSS classes to apply to the grid container\n */\n className?: string\n /**\n * Rotation angle of the grid in degrees\n * @default 65\n */\n angle?: number\n /**\n * Grid cell size in pixels\n * @default 60\n */\n cellSize?: number\n /**\n * Grid opacity value between 0 and 1\n * @default 0.5\n */\n opacity?: number\n /**\n * Grid line color in light mode\n * @default \"gray\"\n */\n lightLineColor?: string\n /**\n * Grid line color in dark mode\n * @default \"gray\"\n */\n darkLineColor?: string\n}\n\nexport function RetroGrid({\n className,\n angle = 65,\n cellSize = 60,\n opacity = 0.5,\n lightLineColor = \"gray\",\n darkLineColor = \"gray\",\n ...props\n}: RetroGridProps) {\n const gridStyles = {\n \"--grid-angle\": `${angle}deg`,\n \"--cell-size\": `${cellSize}px`,\n \"--opacity\": opacity,\n \"--light-line\": lightLineColor,\n \"--dark-line\": darkLineColor,\n } as React.CSSProperties\n\n return (\n \n
\n
\n
\n\n
\n
\n )\n}\n", + "content": "\"use client\"\n\nimport { useEffect, useRef, useState } from \"react\"\nimport type { CSSProperties, HTMLAttributes } from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst ANIMATION_DURATION_SECONDS = 15\nconst GRID_HEIGHT_RATIO = 3\nconst GRID_LINE_ALIGNMENT_OFFSET_PX = 0.5\nconst GRID_LINE_ANTIALIAS_MULTIPLIER = 0.9\nconst GRID_LINE_WIDTH_PX = 0.92\nconst GRID_START_OFFSET_RATIO = -0.5\nconst GRID_WIDTH_RATIO = 6\nconst GRID_X_OFFSET_RATIO = -2\nconst MAX_ANGLE = 89\nconst MAX_DEVICE_PIXEL_RATIO = 2\nconst MIN_ANGLE = 1\nconst PERSPECTIVE_PX = 200\nconst FALLBACK_ANIMATION_NAME = \"retro-grid-fallback-scroll\"\nconst FALLBACK_STYLES = `\n@keyframes ${FALLBACK_ANIMATION_NAME} {\n from {\n transform: translateY(-50%);\n }\n\n to {\n transform: translateY(0);\n }\n}\n\n@media (prefers-reduced-motion: reduce) {\n [data-retro-grid-scroll=\"true\"] {\n animation: none !important;\n transform: translateY(-50%) !important;\n }\n}\n`\n\nconst VERTEX_SHADER_SOURCE = `\nattribute vec2 a_position;\n\nvoid main() {\n gl_Position = vec4(a_position, 0.0, 1.0);\n}\n`\n\nconst FRAGMENT_SHADER_SOURCE = `\n#extension GL_OES_standard_derivatives : enable\nprecision highp float;\n\nuniform vec2 u_container_size;\nuniform vec2 u_viewport_size;\nuniform vec4 u_line_color;\nuniform float u_angle;\nuniform float u_cell_size;\nuniform float u_device_pixel_ratio;\nuniform float u_time;\n\nconst float animationDurationSeconds = ${ANIMATION_DURATION_SECONDS.toFixed(1)};\nconst float gridHeightRatio = ${GRID_HEIGHT_RATIO.toFixed(1)};\nconst float gridStartOffsetRatio = ${GRID_START_OFFSET_RATIO.toFixed(1)};\nconst float gridWidthRatio = ${GRID_WIDTH_RATIO.toFixed(1)};\nconst float gridXOffsetRatio = ${GRID_X_OFFSET_RATIO.toFixed(1)};\nconst float gridLineAlignmentOffsetPx = ${GRID_LINE_ALIGNMENT_OFFSET_PX.toFixed(1)};\nconst float gridLineAntialiasMultiplier = ${GRID_LINE_ANTIALIAS_MULTIPLIER.toFixed(1)};\nconst float horizontalLodLevelOneEndPx = 5.6;\nconst float horizontalLodLevelOneStartPx = 2.8;\nconst float horizontalLodLevelTwoEndPx = 3.0;\nconst float horizontalLodLevelTwoStartPx = 1.4;\nconst float horizontalCompressionEndPx = 2.8;\nconst float horizontalCompressionStartPx = 1.2;\nconst float lineWidthPx = ${GRID_LINE_WIDTH_PX.toFixed(2)};\nconst float perspectivePx = ${PERSPECTIVE_PX.toFixed(1)};\nconst float gridTravelRatio = 0.5;\nconst float verticalCompressionEndPx = 2.6;\nconst float verticalCompressionStartPx = 1.0;\nconst float verticalEdgeCompressionEnd = 0.95;\nconst float verticalEdgeCompressionStart = 0.45;\nconst float verticalLodLevelEnd = 0.64;\nconst float verticalLodLevelStart = 0.22;\nconst float verticalTopCompressionEndCells = 6.0;\nconst float verticalTopCompressionStartCells = 2.0;\n\nfloat renderGridLine(\n float wrappedCoord,\n float antiAliasWidth,\n float softnessBoost\n) {\n return 1.0 - smoothstep(\n lineWidthPx,\n lineWidthPx + (antiAliasWidth * (1.5 + softnessBoost)),\n wrappedCoord\n );\n}\n\nvoid main() {\n float angle = radians(clamp(u_angle, 1.0, 89.0));\n float sinAngle = sin(angle);\n float cosAngle = cos(angle);\n vec2 screen = vec2(\n (gl_FragCoord.x / u_device_pixel_ratio) - (u_container_size.x * 0.5),\n (u_container_size.y * 0.5) - (gl_FragCoord.y / u_device_pixel_ratio)\n );\n\n vec3 rayOrigin = vec3(0.0, 0.0, perspectivePx);\n vec3 rayDirection = normalize(vec3(screen, -perspectivePx));\n vec3 planeXAxis = vec3(1.0, 0.0, 0.0);\n vec3 planeYAxis = vec3(0.0, cosAngle, sinAngle);\n vec3 planeNormal = normalize(cross(planeXAxis, planeYAxis));\n float denominator = dot(rayDirection, planeNormal);\n\n if (abs(denominator) < 0.0001) {\n discard;\n }\n\n float distanceToPlane = dot(-rayOrigin, planeNormal) / denominator;\n\n if (distanceToPlane <= 0.0) {\n discard;\n }\n\n vec3 hitPoint = rayOrigin + (rayDirection * distanceToPlane);\n float localX = hitPoint.x;\n float localY = dot(hitPoint, planeYAxis);\n float gridWidth = u_viewport_size.x * gridWidthRatio;\n float gridHeight = u_viewport_size.y * gridHeightRatio;\n float gridScrollSpeed = (gridHeight * gridTravelRatio) / animationDurationSeconds;\n float patternOffsetY = u_time * gridScrollSpeed;\n float gridLeft = (-0.5 * u_container_size.x) + (gridXOffsetRatio * u_container_size.x);\n float gridTop = (-0.5 * u_container_size.y) + (gridStartOffsetRatio * gridHeight);\n vec2 planePosition = vec2(localX - gridLeft, localY - gridTop);\n\n if (\n planePosition.x < 0.0 ||\n planePosition.y < 0.0 ||\n planePosition.x > gridWidth ||\n planePosition.y > gridHeight\n ) {\n discard;\n }\n\n vec2 patternPosition = vec2(planePosition.x, planePosition.y - patternOffsetY);\n vec2 wrapped = mod(\n patternPosition + vec2(gridLineAlignmentOffsetPx),\n u_cell_size\n );\n vec2 patternDerivative = max(fwidth(patternPosition), vec2(0.0001));\n vec2 antiAliasWidth = patternDerivative * gridLineAntialiasMultiplier;\n float horizontalCellSpanPx = u_cell_size / patternDerivative.y;\n float horizontalCompression = 1.0 - smoothstep(\n horizontalCompressionStartPx,\n horizontalCompressionEndPx,\n horizontalCellSpanPx\n );\n float verticalCellSpanPx = u_cell_size / patternDerivative.x;\n float sideDistance = abs((planePosition.x / gridWidth) * 2.0 - 1.0);\n float verticalEdgeCompression = smoothstep(\n verticalEdgeCompressionStart,\n verticalEdgeCompressionEnd,\n sideDistance\n );\n float verticalTopCompression = 1.0 - smoothstep(\n u_cell_size * verticalTopCompressionStartCells,\n u_cell_size * verticalTopCompressionEndCells,\n planePosition.y\n );\n float verticalCompression =\n (1.0 - smoothstep(\n verticalCompressionStartPx,\n verticalCompressionEndPx,\n verticalCellSpanPx\n )) * verticalEdgeCompression * verticalTopCompression;\n float horizontalSoftnessBoost = 1.0 + (horizontalCompression * 3.0);\n float verticalSoftnessBoost = 1.0 + (verticalCompression * 3.5);\n float verticalLod = smoothstep(\n verticalLodLevelStart,\n verticalLodLevelEnd,\n verticalCompression\n );\n float verticalLineFine = renderGridLine(\n wrapped.x,\n antiAliasWidth.x,\n verticalSoftnessBoost\n );\n float verticalWrappedLod = mod(\n patternPosition.x + gridLineAlignmentOffsetPx,\n u_cell_size * 2.0\n );\n float verticalLineCoarse = renderGridLine(\n verticalWrappedLod,\n antiAliasWidth.x,\n verticalSoftnessBoost + verticalLod\n );\n float verticalLine = max(\n verticalLineFine * (1.0 - verticalLod),\n verticalLineCoarse * verticalLod\n );\n float horizontalLodLevelOne = 1.0 - smoothstep(\n horizontalLodLevelOneStartPx,\n horizontalLodLevelOneEndPx,\n horizontalCellSpanPx\n );\n float horizontalLodLevelTwo = 1.0 - smoothstep(\n horizontalLodLevelTwoStartPx,\n horizontalLodLevelTwoEndPx,\n horizontalCellSpanPx\n );\n float horizontalLineFine = renderGridLine(\n wrapped.y,\n antiAliasWidth.y,\n horizontalSoftnessBoost\n );\n float horizontalWrappedLodOne = mod(\n patternPosition.y + gridLineAlignmentOffsetPx,\n u_cell_size * 2.0\n );\n float horizontalWrappedLodTwo = mod(\n patternPosition.y + gridLineAlignmentOffsetPx,\n u_cell_size * 4.0\n );\n float horizontalLineCoarse = renderGridLine(\n horizontalWrappedLodOne,\n antiAliasWidth.y,\n horizontalSoftnessBoost + horizontalLodLevelOne\n );\n float horizontalLineExtraCoarse = renderGridLine(\n horizontalWrappedLodTwo,\n antiAliasWidth.y,\n horizontalSoftnessBoost + horizontalLodLevelOne + horizontalLodLevelTwo\n );\n float horizontalLineReduced = max(\n horizontalLineFine * (1.0 - horizontalLodLevelOne),\n horizontalLineCoarse * horizontalLodLevelOne\n );\n float horizontalLine = max(\n horizontalLineReduced * (1.0 - horizontalLodLevelTwo),\n horizontalLineExtraCoarse * horizontalLodLevelTwo\n );\n float line = max(verticalLine, horizontalLine);\n\n if (line <= 0.001) {\n discard;\n }\n\n float alpha = u_line_color.a * line;\n gl_FragColor = vec4(u_line_color.rgb * alpha, alpha);\n}\n`\n\ninterface RetroGridProps extends HTMLAttributes {\n /**\n * Additional CSS classes to apply to the grid container\n */\n className?: string\n /**\n * Rotation angle of the grid in degrees\n * @default 65\n */\n angle?: number\n /**\n * Grid cell size in pixels\n * @default 60\n */\n cellSize?: number\n /**\n * Grid opacity value between 0 and 1\n * @default 0.5\n */\n opacity?: number\n /**\n * Grid line color in light mode\n * @default \"gray\"\n */\n lightLineColor?: string\n /**\n * Grid line color in dark mode\n * @default \"gray\"\n */\n darkLineColor?: string\n}\n\ninterface ProgramInfo {\n attributeLocation: number\n program: WebGLProgram\n uniforms: {\n angle: WebGLUniformLocation\n cellSize: WebGLUniformLocation\n containerSize: WebGLUniformLocation\n devicePixelRatio: WebGLUniformLocation\n lineColor: WebGLUniformLocation\n time: WebGLUniformLocation\n viewportSize: WebGLUniformLocation\n }\n}\n\nlet colorResolveContext: CanvasRenderingContext2D | null | undefined\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(Math.max(value, min), max)\n}\n\nfunction createShader(gl: WebGLRenderingContext, type: number, source: string) {\n const shader = gl.createShader(type)\n\n if (!shader) {\n return null\n }\n\n gl.shaderSource(shader, source)\n gl.compileShader(shader)\n\n if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n return shader\n }\n\n gl.deleteShader(shader)\n return null\n}\n\nfunction createProgram(gl: WebGLRenderingContext) {\n const vertexShader = createShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER_SOURCE)\n const fragmentShader = createShader(\n gl,\n gl.FRAGMENT_SHADER,\n FRAGMENT_SHADER_SOURCE\n )\n\n if (!vertexShader || !fragmentShader) {\n return null\n }\n\n const program = gl.createProgram()\n\n if (!program) {\n gl.deleteShader(vertexShader)\n gl.deleteShader(fragmentShader)\n return null\n }\n\n gl.attachShader(program, vertexShader)\n gl.attachShader(program, fragmentShader)\n gl.linkProgram(program)\n gl.deleteShader(vertexShader)\n gl.deleteShader(fragmentShader)\n\n if (gl.getProgramParameter(program, gl.LINK_STATUS)) {\n return program\n }\n\n gl.deleteProgram(program)\n return null\n}\n\nfunction getProgramInfo(\n gl: WebGLRenderingContext,\n program: WebGLProgram\n): ProgramInfo | null {\n const attributeLocation = gl.getAttribLocation(program, \"a_position\")\n const angle = gl.getUniformLocation(program, \"u_angle\")\n const cellSize = gl.getUniformLocation(program, \"u_cell_size\")\n const containerSize = gl.getUniformLocation(program, \"u_container_size\")\n const devicePixelRatio = gl.getUniformLocation(\n program,\n \"u_device_pixel_ratio\"\n )\n const lineColor = gl.getUniformLocation(program, \"u_line_color\")\n const time = gl.getUniformLocation(program, \"u_time\")\n const viewportSize = gl.getUniformLocation(program, \"u_viewport_size\")\n\n if (\n attributeLocation < 0 ||\n !angle ||\n !cellSize ||\n !containerSize ||\n !devicePixelRatio ||\n !lineColor ||\n !time ||\n !viewportSize\n ) {\n return null\n }\n\n return {\n attributeLocation,\n program,\n uniforms: {\n angle,\n cellSize,\n containerSize,\n devicePixelRatio,\n lineColor,\n time,\n viewportSize,\n },\n }\n}\n\nfunction isDarkMode(colorScheme: MediaQueryList) {\n const root = document.documentElement\n\n if (root.classList.contains(\"dark\")) {\n return true\n }\n\n if (root.classList.contains(\"light\")) {\n return false\n }\n\n return colorScheme.matches\n}\n\nfunction getColorResolveContext() {\n if (colorResolveContext !== undefined) {\n return colorResolveContext\n }\n\n const canvas = document.createElement(\"canvas\")\n canvas.width = 1\n canvas.height = 1\n colorResolveContext = canvas.getContext(\"2d\", {\n willReadFrequently: true,\n })\n\n return colorResolveContext\n}\n\nfunction resolveLineColor(color: string, element: HTMLElement) {\n const resolver = document.createElement(\"span\")\n resolver.style.color = color\n resolver.style.opacity = \"0\"\n resolver.style.pointerEvents = \"none\"\n resolver.style.position = \"absolute\"\n element.appendChild(resolver)\n\n const resolvedColor = getComputedStyle(resolver).color\n resolver.remove()\n const context = getColorResolveContext()\n\n if (!context) {\n return new Float32Array([0.5, 0.5, 0.5, 1])\n }\n\n context.clearRect(0, 0, 1, 1)\n context.fillStyle = resolvedColor\n context.fillRect(0, 0, 1, 1)\n const pixel = context.getImageData(0, 0, 1, 1).data\n\n return new Float32Array([\n pixel[0] / 255,\n pixel[1] / 255,\n pixel[2] / 255,\n pixel[3] / 255,\n ])\n}\n\nfunction createFallbackGridStyle(\n cellSize: number,\n lineColor: string\n): CSSProperties {\n return {\n animation: `${FALLBACK_ANIMATION_NAME} ${ANIMATION_DURATION_SECONDS}s linear infinite`,\n backgroundImage: `linear-gradient(to right, ${lineColor} 1px, transparent 0), linear-gradient(to bottom, ${lineColor} 1px, transparent 0)`,\n backgroundRepeat: \"repeat\",\n backgroundSize: `${cellSize}px ${cellSize}px`,\n transform: \"translateY(-50%)\",\n }\n}\n\nexport function RetroGrid({\n className,\n angle = 65,\n cellSize = 60,\n opacity = 0.5,\n lightLineColor = \"gray\",\n darkLineColor = \"gray\",\n style,\n ...props\n}: RetroGridProps) {\n const canvasRef = useRef(null)\n const containerRef = useRef(null)\n const [isWebGlReady, setIsWebGlReady] = useState(false)\n const angleRef = useRef(angle)\n const cellSizeRef = useRef(cellSize)\n const darkLineColorRef = useRef(darkLineColor)\n const lightLineColorRef = useRef(lightLineColor)\n const syncSceneRef = useRef<(() => void) | null>(null)\n\n useEffect(() => {\n angleRef.current = angle\n cellSizeRef.current = cellSize\n darkLineColorRef.current = darkLineColor\n lightLineColorRef.current = lightLineColor\n syncSceneRef.current?.()\n }, [angle, cellSize, darkLineColor, lightLineColor])\n\n useEffect(() => {\n const canvas = canvasRef.current\n const container = containerRef.current\n\n if (!canvas || !container) {\n return\n }\n\n const reducedMotion = window.matchMedia(\"(prefers-reduced-motion: reduce)\")\n const colorScheme = window.matchMedia(\"(prefers-color-scheme: dark)\")\n\n let animationFrameId: number | null = null\n let currentWidth = 0\n let currentHeight = 0\n let currentDevicePixelRatio = 1\n let gl: WebGLRenderingContext | null = null\n let isVisible = true\n let isContextLost = false\n let lineColor = resolveLineColor(lightLineColorRef.current, container)\n let positionBuffer: WebGLBuffer | null = null\n let programInfo: ProgramInfo | null = null\n\n const getContext = () => {\n const nextGl = canvas.getContext(\"webgl\", {\n alpha: true,\n antialias: true,\n premultipliedAlpha: true,\n })\n\n if (!nextGl || !nextGl.getExtension(\"OES_standard_derivatives\")) {\n return null\n }\n\n return nextGl\n }\n\n const releasePipeline = (shouldDeleteResources: boolean) => {\n if (shouldDeleteResources && gl) {\n if (positionBuffer) {\n gl.deleteBuffer(positionBuffer)\n }\n\n if (programInfo) {\n gl.deleteProgram(programInfo.program)\n }\n }\n\n positionBuffer = null\n programInfo = null\n\n if (shouldDeleteResources) {\n gl = null\n }\n }\n\n const initializePipeline = () => {\n const nextGl = getContext()\n\n if (!nextGl) {\n releasePipeline(false)\n return false\n }\n\n gl = nextGl\n releasePipeline(true)\n gl = nextGl\n\n const program = createProgram(nextGl)\n\n if (!program) {\n return false\n }\n\n const nextProgramInfo = getProgramInfo(nextGl, program)\n\n if (!nextProgramInfo) {\n nextGl.deleteProgram(program)\n return false\n }\n\n const nextPositionBuffer = nextGl.createBuffer()\n\n if (!nextPositionBuffer) {\n nextGl.deleteProgram(program)\n return false\n }\n\n nextGl.bindBuffer(nextGl.ARRAY_BUFFER, nextPositionBuffer)\n nextGl.bufferData(\n nextGl.ARRAY_BUFFER,\n new Float32Array([-1, -1, 3, -1, -1, 3]),\n nextGl.STATIC_DRAW\n )\n\n positionBuffer = nextPositionBuffer\n programInfo = nextProgramInfo\n\n return true\n }\n\n const updateLineColor = () => {\n const activeColor = isDarkMode(colorScheme)\n ? darkLineColorRef.current\n : lightLineColorRef.current\n lineColor = resolveLineColor(activeColor, container)\n }\n\n const resizeCanvas = () => {\n currentWidth = Math.floor(container.clientWidth)\n currentHeight = Math.floor(container.clientHeight)\n\n if (currentWidth === 0 || currentHeight === 0 || !gl) {\n return\n }\n\n currentDevicePixelRatio = Math.min(\n window.devicePixelRatio || 1,\n MAX_DEVICE_PIXEL_RATIO\n )\n\n canvas.width = Math.floor(currentWidth * currentDevicePixelRatio)\n canvas.height = Math.floor(currentHeight * currentDevicePixelRatio)\n canvas.style.width = `${currentWidth}px`\n canvas.style.height = `${currentHeight}px`\n gl.viewport(0, 0, canvas.width, canvas.height)\n }\n\n const draw = (timestamp: number) => {\n if (\n currentWidth === 0 ||\n currentHeight === 0 ||\n !gl ||\n !positionBuffer ||\n !programInfo ||\n isContextLost\n ) {\n return\n }\n\n gl.useProgram(programInfo.program)\n gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)\n gl.enableVertexAttribArray(programInfo.attributeLocation)\n gl.vertexAttribPointer(\n programInfo.attributeLocation,\n 2,\n gl.FLOAT,\n false,\n 0,\n 0\n )\n gl.clearColor(0, 0, 0, 0)\n gl.clear(gl.COLOR_BUFFER_BIT)\n gl.uniform1f(\n programInfo.uniforms.angle,\n clamp(angleRef.current, MIN_ANGLE, MAX_ANGLE)\n )\n gl.uniform1f(\n programInfo.uniforms.cellSize,\n Math.max(cellSizeRef.current, 1)\n )\n gl.uniform2f(\n programInfo.uniforms.containerSize,\n currentWidth,\n currentHeight\n )\n gl.uniform1f(\n programInfo.uniforms.devicePixelRatio,\n currentDevicePixelRatio\n )\n gl.uniform4fv(programInfo.uniforms.lineColor, lineColor)\n gl.uniform1f(\n programInfo.uniforms.time,\n reducedMotion.matches ? 0 : timestamp / 1000\n )\n gl.uniform2f(\n programInfo.uniforms.viewportSize,\n window.innerWidth,\n window.innerHeight\n )\n gl.drawArrays(gl.TRIANGLES, 0, 3)\n }\n\n const stopAnimation = () => {\n if (animationFrameId !== null) {\n cancelAnimationFrame(animationFrameId)\n animationFrameId = null\n }\n }\n\n const frame = (timestamp: number) => {\n draw(timestamp)\n\n if (!reducedMotion.matches && isVisible) {\n animationFrameId = requestAnimationFrame(frame)\n return\n }\n\n animationFrameId = null\n }\n\n const syncScene = () => {\n if (isContextLost) {\n stopAnimation()\n setIsWebGlReady(false)\n return\n }\n\n if (!gl || !positionBuffer || !programInfo) {\n if (!initializePipeline()) {\n stopAnimation()\n setIsWebGlReady(false)\n return\n }\n }\n\n resizeCanvas()\n\n if (currentWidth === 0 || currentHeight === 0) {\n stopAnimation()\n return\n }\n\n updateLineColor()\n draw(performance.now())\n setIsWebGlReady(true)\n\n if (reducedMotion.matches || !isVisible) {\n stopAnimation()\n return\n }\n\n if (animationFrameId === null) {\n animationFrameId = requestAnimationFrame(frame)\n }\n }\n\n syncSceneRef.current = syncScene\n\n const resizeObserver = new ResizeObserver(() => {\n syncScene()\n })\n resizeObserver.observe(container)\n\n const handleWindowResize = () => {\n syncScene()\n }\n\n const intersectionObserver = new IntersectionObserver(([entry]) => {\n isVisible = entry?.isIntersecting ?? false\n\n if (isVisible) {\n syncScene()\n return\n }\n\n stopAnimation()\n })\n intersectionObserver.observe(container)\n\n const themeObserver = new MutationObserver(() => {\n syncScene()\n })\n themeObserver.observe(document.documentElement, {\n attributeFilter: [\"class\"],\n attributes: true,\n })\n\n const handleMotionChange = () => {\n syncScene()\n }\n\n const handleColorSchemeChange = () => {\n syncScene()\n }\n\n const handleContextLost = (event: Event) => {\n event.preventDefault()\n isContextLost = true\n stopAnimation()\n releasePipeline(false)\n setIsWebGlReady(false)\n }\n\n const handleContextRestored = () => {\n isContextLost = false\n syncScene()\n }\n\n reducedMotion.addEventListener(\"change\", handleMotionChange)\n colorScheme.addEventListener(\"change\", handleColorSchemeChange)\n window.addEventListener(\"resize\", handleWindowResize)\n canvas.addEventListener(\"webglcontextlost\", handleContextLost)\n canvas.addEventListener(\"webglcontextrestored\", handleContextRestored)\n\n syncScene()\n\n return () => {\n stopAnimation()\n resizeObserver.disconnect()\n intersectionObserver.disconnect()\n themeObserver.disconnect()\n reducedMotion.removeEventListener(\"change\", handleMotionChange)\n colorScheme.removeEventListener(\"change\", handleColorSchemeChange)\n window.removeEventListener(\"resize\", handleWindowResize)\n canvas.removeEventListener(\"webglcontextlost\", handleContextLost)\n canvas.removeEventListener(\"webglcontextrestored\", handleContextRestored)\n syncSceneRef.current = null\n releasePipeline(!isContextLost)\n }\n }, [])\n\n const gridStyles = {\n ...style,\n opacity,\n } as CSSProperties\n const normalizedAngle = clamp(angle, MIN_ANGLE, MAX_ANGLE)\n const normalizedCellSize = Math.max(cellSize, 1)\n const fallbackProjectionStyles = {\n perspective: `${PERSPECTIVE_PX}px`,\n } as CSSProperties\n const fallbackRotationStyles = {\n transform: `rotateX(${normalizedAngle}deg)`,\n } as CSSProperties\n const lightFallbackGridStyles = createFallbackGridStyle(\n normalizedCellSize,\n lightLineColor\n )\n const darkFallbackGridStyles = createFallbackGridStyle(\n normalizedCellSize,\n darkLineColor\n )\n\n return (\n \n \n {!isWebGlReady ? (\n
\n
\n \n
\n
\n ) : null}\n \n
\n
\n )\n}\n", "type": "registry:ui" } - ], - "cssVars": { - "theme": { - "animate-grid": "grid 15s linear infinite" - } - }, - "css": { - "@keyframes grid": { - "0%": { - "transform": "translateY(-50%)" - }, - "100%": { - "transform": "translateY(0)" - } - } - } + ] } \ No newline at end of file diff --git a/apps/www/public/registry.json b/apps/www/public/registry.json index 724c9df2e..ec7e83ecd 100644 --- a/apps/www/public/registry.json +++ b/apps/www/public/registry.json @@ -569,22 +569,7 @@ "path": "registry/magicui/retro-grid.tsx", "type": "registry:ui" } - ], - "cssVars": { - "theme": { - "animate-grid": "grid 15s linear infinite" - } - }, - "css": { - "@keyframes grid": { - "0%": { - "transform": "translateY(-50%)" - }, - "100%": { - "transform": "translateY(0)" - } - } - } + ] }, { "name": "animated-list", diff --git a/apps/www/registry.json b/apps/www/registry.json index 724c9df2e..ec7e83ecd 100644 --- a/apps/www/registry.json +++ b/apps/www/registry.json @@ -569,22 +569,7 @@ "path": "registry/magicui/retro-grid.tsx", "type": "registry:ui" } - ], - "cssVars": { - "theme": { - "animate-grid": "grid 15s linear infinite" - } - }, - "css": { - "@keyframes grid": { - "0%": { - "transform": "translateY(-50%)" - }, - "100%": { - "transform": "translateY(0)" - } - } - } + ] }, { "name": "animated-list", diff --git a/apps/www/registry/magicui/retro-grid.tsx b/apps/www/registry/magicui/retro-grid.tsx index 5c2c9172f..aee04bbe4 100644 --- a/apps/www/registry/magicui/retro-grid.tsx +++ b/apps/www/registry/magicui/retro-grid.tsx @@ -1,6 +1,254 @@ +"use client" + +import { useEffect, useRef, useState } from "react" +import type { CSSProperties, HTMLAttributes } from "react" + import { cn } from "@/lib/utils" -interface RetroGridProps extends React.HTMLAttributes { +const ANIMATION_DURATION_SECONDS = 15 +const GRID_HEIGHT_RATIO = 3 +const GRID_LINE_ALIGNMENT_OFFSET_PX = 0.5 +const GRID_LINE_ANTIALIAS_MULTIPLIER = 0.9 +const GRID_LINE_WIDTH_PX = 0.92 +const GRID_START_OFFSET_RATIO = -0.5 +const GRID_WIDTH_RATIO = 6 +const GRID_X_OFFSET_RATIO = -2 +const MAX_ANGLE = 89 +const MAX_DEVICE_PIXEL_RATIO = 2 +const MIN_ANGLE = 1 +const PERSPECTIVE_PX = 200 +const FALLBACK_ANIMATION_NAME = "retro-grid-fallback-scroll" +const FALLBACK_STYLES = ` +@keyframes ${FALLBACK_ANIMATION_NAME} { + from { + transform: translateY(-50%); + } + + to { + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + [data-retro-grid-scroll="true"] { + animation: none !important; + transform: translateY(-50%) !important; + } +} +` + +const VERTEX_SHADER_SOURCE = ` +attribute vec2 a_position; + +void main() { + gl_Position = vec4(a_position, 0.0, 1.0); +} +` + +const FRAGMENT_SHADER_SOURCE = ` +#extension GL_OES_standard_derivatives : enable +precision highp float; + +uniform vec2 u_container_size; +uniform vec2 u_viewport_size; +uniform vec4 u_line_color; +uniform float u_angle; +uniform float u_cell_size; +uniform float u_device_pixel_ratio; +uniform float u_time; + +const float animationDurationSeconds = ${ANIMATION_DURATION_SECONDS.toFixed(1)}; +const float gridHeightRatio = ${GRID_HEIGHT_RATIO.toFixed(1)}; +const float gridStartOffsetRatio = ${GRID_START_OFFSET_RATIO.toFixed(1)}; +const float gridWidthRatio = ${GRID_WIDTH_RATIO.toFixed(1)}; +const float gridXOffsetRatio = ${GRID_X_OFFSET_RATIO.toFixed(1)}; +const float gridLineAlignmentOffsetPx = ${GRID_LINE_ALIGNMENT_OFFSET_PX.toFixed(1)}; +const float gridLineAntialiasMultiplier = ${GRID_LINE_ANTIALIAS_MULTIPLIER.toFixed(1)}; +const float horizontalLodLevelOneEndPx = 5.6; +const float horizontalLodLevelOneStartPx = 2.8; +const float horizontalLodLevelTwoEndPx = 3.0; +const float horizontalLodLevelTwoStartPx = 1.4; +const float horizontalCompressionEndPx = 2.8; +const float horizontalCompressionStartPx = 1.2; +const float lineWidthPx = ${GRID_LINE_WIDTH_PX.toFixed(2)}; +const float perspectivePx = ${PERSPECTIVE_PX.toFixed(1)}; +const float gridTravelRatio = 0.5; +const float verticalCompressionEndPx = 2.6; +const float verticalCompressionStartPx = 1.0; +const float verticalEdgeCompressionEnd = 0.95; +const float verticalEdgeCompressionStart = 0.45; +const float verticalLodLevelEnd = 0.64; +const float verticalLodLevelStart = 0.22; +const float verticalTopCompressionEndCells = 6.0; +const float verticalTopCompressionStartCells = 2.0; + +float renderGridLine( + float wrappedCoord, + float antiAliasWidth, + float softnessBoost +) { + return 1.0 - smoothstep( + lineWidthPx, + lineWidthPx + (antiAliasWidth * (1.5 + softnessBoost)), + wrappedCoord + ); +} + +void main() { + float angle = radians(clamp(u_angle, 1.0, 89.0)); + float sinAngle = sin(angle); + float cosAngle = cos(angle); + vec2 screen = vec2( + (gl_FragCoord.x / u_device_pixel_ratio) - (u_container_size.x * 0.5), + (u_container_size.y * 0.5) - (gl_FragCoord.y / u_device_pixel_ratio) + ); + + vec3 rayOrigin = vec3(0.0, 0.0, perspectivePx); + vec3 rayDirection = normalize(vec3(screen, -perspectivePx)); + vec3 planeXAxis = vec3(1.0, 0.0, 0.0); + vec3 planeYAxis = vec3(0.0, cosAngle, sinAngle); + vec3 planeNormal = normalize(cross(planeXAxis, planeYAxis)); + float denominator = dot(rayDirection, planeNormal); + + if (abs(denominator) < 0.0001) { + discard; + } + + float distanceToPlane = dot(-rayOrigin, planeNormal) / denominator; + + if (distanceToPlane <= 0.0) { + discard; + } + + vec3 hitPoint = rayOrigin + (rayDirection * distanceToPlane); + float localX = hitPoint.x; + float localY = dot(hitPoint, planeYAxis); + float gridWidth = u_viewport_size.x * gridWidthRatio; + float gridHeight = u_viewport_size.y * gridHeightRatio; + float gridScrollSpeed = (gridHeight * gridTravelRatio) / animationDurationSeconds; + float patternOffsetY = u_time * gridScrollSpeed; + float gridLeft = (-0.5 * u_container_size.x) + (gridXOffsetRatio * u_container_size.x); + float gridTop = (-0.5 * u_container_size.y) + (gridStartOffsetRatio * gridHeight); + vec2 planePosition = vec2(localX - gridLeft, localY - gridTop); + + if ( + planePosition.x < 0.0 || + planePosition.y < 0.0 || + planePosition.x > gridWidth || + planePosition.y > gridHeight + ) { + discard; + } + + vec2 patternPosition = vec2(planePosition.x, planePosition.y - patternOffsetY); + vec2 wrapped = mod( + patternPosition + vec2(gridLineAlignmentOffsetPx), + u_cell_size + ); + vec2 patternDerivative = max(fwidth(patternPosition), vec2(0.0001)); + vec2 antiAliasWidth = patternDerivative * gridLineAntialiasMultiplier; + float horizontalCellSpanPx = u_cell_size / patternDerivative.y; + float horizontalCompression = 1.0 - smoothstep( + horizontalCompressionStartPx, + horizontalCompressionEndPx, + horizontalCellSpanPx + ); + float verticalCellSpanPx = u_cell_size / patternDerivative.x; + float sideDistance = abs((planePosition.x / gridWidth) * 2.0 - 1.0); + float verticalEdgeCompression = smoothstep( + verticalEdgeCompressionStart, + verticalEdgeCompressionEnd, + sideDistance + ); + float verticalTopCompression = 1.0 - smoothstep( + u_cell_size * verticalTopCompressionStartCells, + u_cell_size * verticalTopCompressionEndCells, + planePosition.y + ); + float verticalCompression = + (1.0 - smoothstep( + verticalCompressionStartPx, + verticalCompressionEndPx, + verticalCellSpanPx + )) * verticalEdgeCompression * verticalTopCompression; + float horizontalSoftnessBoost = 1.0 + (horizontalCompression * 3.0); + float verticalSoftnessBoost = 1.0 + (verticalCompression * 3.5); + float verticalLod = smoothstep( + verticalLodLevelStart, + verticalLodLevelEnd, + verticalCompression + ); + float verticalLineFine = renderGridLine( + wrapped.x, + antiAliasWidth.x, + verticalSoftnessBoost + ); + float verticalWrappedLod = mod( + patternPosition.x + gridLineAlignmentOffsetPx, + u_cell_size * 2.0 + ); + float verticalLineCoarse = renderGridLine( + verticalWrappedLod, + antiAliasWidth.x, + verticalSoftnessBoost + verticalLod + ); + float verticalLine = max( + verticalLineFine * (1.0 - verticalLod), + verticalLineCoarse * verticalLod + ); + float horizontalLodLevelOne = 1.0 - smoothstep( + horizontalLodLevelOneStartPx, + horizontalLodLevelOneEndPx, + horizontalCellSpanPx + ); + float horizontalLodLevelTwo = 1.0 - smoothstep( + horizontalLodLevelTwoStartPx, + horizontalLodLevelTwoEndPx, + horizontalCellSpanPx + ); + float horizontalLineFine = renderGridLine( + wrapped.y, + antiAliasWidth.y, + horizontalSoftnessBoost + ); + float horizontalWrappedLodOne = mod( + patternPosition.y + gridLineAlignmentOffsetPx, + u_cell_size * 2.0 + ); + float horizontalWrappedLodTwo = mod( + patternPosition.y + gridLineAlignmentOffsetPx, + u_cell_size * 4.0 + ); + float horizontalLineCoarse = renderGridLine( + horizontalWrappedLodOne, + antiAliasWidth.y, + horizontalSoftnessBoost + horizontalLodLevelOne + ); + float horizontalLineExtraCoarse = renderGridLine( + horizontalWrappedLodTwo, + antiAliasWidth.y, + horizontalSoftnessBoost + horizontalLodLevelOne + horizontalLodLevelTwo + ); + float horizontalLineReduced = max( + horizontalLineFine * (1.0 - horizontalLodLevelOne), + horizontalLineCoarse * horizontalLodLevelOne + ); + float horizontalLine = max( + horizontalLineReduced * (1.0 - horizontalLodLevelTwo), + horizontalLineExtraCoarse * horizontalLodLevelTwo + ); + float line = max(verticalLine, horizontalLine); + + if (line <= 0.001) { + discard; + } + + float alpha = u_line_color.a * line; + gl_FragColor = vec4(u_line_color.rgb * alpha, alpha); +} +` + +interface RetroGridProps extends HTMLAttributes { /** * Additional CSS classes to apply to the grid container */ @@ -32,6 +280,193 @@ interface RetroGridProps extends React.HTMLAttributes { darkLineColor?: string } +interface ProgramInfo { + attributeLocation: number + program: WebGLProgram + uniforms: { + angle: WebGLUniformLocation + cellSize: WebGLUniformLocation + containerSize: WebGLUniformLocation + devicePixelRatio: WebGLUniformLocation + lineColor: WebGLUniformLocation + time: WebGLUniformLocation + viewportSize: WebGLUniformLocation + } +} + +let colorResolveContext: CanvasRenderingContext2D | null | undefined + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max) +} + +function createShader(gl: WebGLRenderingContext, type: number, source: string) { + const shader = gl.createShader(type) + + if (!shader) { + return null + } + + gl.shaderSource(shader, source) + gl.compileShader(shader) + + if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + return shader + } + + gl.deleteShader(shader) + return null +} + +function createProgram(gl: WebGLRenderingContext) { + const vertexShader = createShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER_SOURCE) + const fragmentShader = createShader( + gl, + gl.FRAGMENT_SHADER, + FRAGMENT_SHADER_SOURCE + ) + + if (!vertexShader || !fragmentShader) { + return null + } + + const program = gl.createProgram() + + if (!program) { + gl.deleteShader(vertexShader) + gl.deleteShader(fragmentShader) + return null + } + + gl.attachShader(program, vertexShader) + gl.attachShader(program, fragmentShader) + gl.linkProgram(program) + gl.deleteShader(vertexShader) + gl.deleteShader(fragmentShader) + + if (gl.getProgramParameter(program, gl.LINK_STATUS)) { + return program + } + + gl.deleteProgram(program) + return null +} + +function getProgramInfo( + gl: WebGLRenderingContext, + program: WebGLProgram +): ProgramInfo | null { + const attributeLocation = gl.getAttribLocation(program, "a_position") + const angle = gl.getUniformLocation(program, "u_angle") + const cellSize = gl.getUniformLocation(program, "u_cell_size") + const containerSize = gl.getUniformLocation(program, "u_container_size") + const devicePixelRatio = gl.getUniformLocation( + program, + "u_device_pixel_ratio" + ) + const lineColor = gl.getUniformLocation(program, "u_line_color") + const time = gl.getUniformLocation(program, "u_time") + const viewportSize = gl.getUniformLocation(program, "u_viewport_size") + + if ( + attributeLocation < 0 || + !angle || + !cellSize || + !containerSize || + !devicePixelRatio || + !lineColor || + !time || + !viewportSize + ) { + return null + } + + return { + attributeLocation, + program, + uniforms: { + angle, + cellSize, + containerSize, + devicePixelRatio, + lineColor, + time, + viewportSize, + }, + } +} + +function isDarkMode(colorScheme: MediaQueryList) { + const root = document.documentElement + + if (root.classList.contains("dark")) { + return true + } + + if (root.classList.contains("light")) { + return false + } + + return colorScheme.matches +} + +function getColorResolveContext() { + if (colorResolveContext !== undefined) { + return colorResolveContext + } + + const canvas = document.createElement("canvas") + canvas.width = 1 + canvas.height = 1 + colorResolveContext = canvas.getContext("2d", { + willReadFrequently: true, + }) + + return colorResolveContext +} + +function resolveLineColor(color: string, element: HTMLElement) { + const resolver = document.createElement("span") + resolver.style.color = color + resolver.style.opacity = "0" + resolver.style.pointerEvents = "none" + resolver.style.position = "absolute" + element.appendChild(resolver) + + const resolvedColor = getComputedStyle(resolver).color + resolver.remove() + const context = getColorResolveContext() + + if (!context) { + return new Float32Array([0.5, 0.5, 0.5, 1]) + } + + context.clearRect(0, 0, 1, 1) + context.fillStyle = resolvedColor + context.fillRect(0, 0, 1, 1) + const pixel = context.getImageData(0, 0, 1, 1).data + + return new Float32Array([ + pixel[0] / 255, + pixel[1] / 255, + pixel[2] / 255, + pixel[3] / 255, + ]) +} + +function createFallbackGridStyle( + cellSize: number, + lineColor: string +): CSSProperties { + return { + animation: `${FALLBACK_ANIMATION_NAME} ${ANIMATION_DURATION_SECONDS}s linear infinite`, + backgroundImage: `linear-gradient(to right, ${lineColor} 1px, transparent 0), linear-gradient(to bottom, ${lineColor} 1px, transparent 0)`, + backgroundRepeat: "repeat", + backgroundSize: `${cellSize}px ${cellSize}px`, + transform: "translateY(-50%)", + } +} + export function RetroGrid({ className, angle = 65, @@ -39,30 +474,392 @@ export function RetroGrid({ opacity = 0.5, lightLineColor = "gray", darkLineColor = "gray", + style, ...props }: RetroGridProps) { + const canvasRef = useRef(null) + const containerRef = useRef(null) + const [isWebGlReady, setIsWebGlReady] = useState(false) + const angleRef = useRef(angle) + const cellSizeRef = useRef(cellSize) + const darkLineColorRef = useRef(darkLineColor) + const lightLineColorRef = useRef(lightLineColor) + const syncSceneRef = useRef<(() => void) | null>(null) + + useEffect(() => { + angleRef.current = angle + cellSizeRef.current = cellSize + darkLineColorRef.current = darkLineColor + lightLineColorRef.current = lightLineColor + syncSceneRef.current?.() + }, [angle, cellSize, darkLineColor, lightLineColor]) + + useEffect(() => { + const canvas = canvasRef.current + const container = containerRef.current + + if (!canvas || !container) { + return + } + + const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)") + const colorScheme = window.matchMedia("(prefers-color-scheme: dark)") + + let animationFrameId: number | null = null + let currentWidth = 0 + let currentHeight = 0 + let currentDevicePixelRatio = 1 + let gl: WebGLRenderingContext | null = null + let isVisible = true + let isContextLost = false + let lineColor = resolveLineColor(lightLineColorRef.current, container) + let positionBuffer: WebGLBuffer | null = null + let programInfo: ProgramInfo | null = null + + const getContext = () => { + const nextGl = canvas.getContext("webgl", { + alpha: true, + antialias: true, + premultipliedAlpha: true, + }) + + if (!nextGl || !nextGl.getExtension("OES_standard_derivatives")) { + return null + } + + return nextGl + } + + const releasePipeline = (shouldDeleteResources: boolean) => { + if (shouldDeleteResources && gl) { + if (positionBuffer) { + gl.deleteBuffer(positionBuffer) + } + + if (programInfo) { + gl.deleteProgram(programInfo.program) + } + } + + positionBuffer = null + programInfo = null + + if (shouldDeleteResources) { + gl = null + } + } + + const initializePipeline = () => { + const nextGl = getContext() + + if (!nextGl) { + releasePipeline(false) + return false + } + + gl = nextGl + releasePipeline(true) + gl = nextGl + + const program = createProgram(nextGl) + + if (!program) { + return false + } + + const nextProgramInfo = getProgramInfo(nextGl, program) + + if (!nextProgramInfo) { + nextGl.deleteProgram(program) + return false + } + + const nextPositionBuffer = nextGl.createBuffer() + + if (!nextPositionBuffer) { + nextGl.deleteProgram(program) + return false + } + + nextGl.bindBuffer(nextGl.ARRAY_BUFFER, nextPositionBuffer) + nextGl.bufferData( + nextGl.ARRAY_BUFFER, + new Float32Array([-1, -1, 3, -1, -1, 3]), + nextGl.STATIC_DRAW + ) + + positionBuffer = nextPositionBuffer + programInfo = nextProgramInfo + + return true + } + + const updateLineColor = () => { + const activeColor = isDarkMode(colorScheme) + ? darkLineColorRef.current + : lightLineColorRef.current + lineColor = resolveLineColor(activeColor, container) + } + + const resizeCanvas = () => { + currentWidth = Math.floor(container.clientWidth) + currentHeight = Math.floor(container.clientHeight) + + if (currentWidth === 0 || currentHeight === 0 || !gl) { + return + } + + currentDevicePixelRatio = Math.min( + window.devicePixelRatio || 1, + MAX_DEVICE_PIXEL_RATIO + ) + + canvas.width = Math.floor(currentWidth * currentDevicePixelRatio) + canvas.height = Math.floor(currentHeight * currentDevicePixelRatio) + canvas.style.width = `${currentWidth}px` + canvas.style.height = `${currentHeight}px` + gl.viewport(0, 0, canvas.width, canvas.height) + } + + const draw = (timestamp: number) => { + if ( + currentWidth === 0 || + currentHeight === 0 || + !gl || + !positionBuffer || + !programInfo || + isContextLost + ) { + return + } + + gl.useProgram(programInfo.program) + gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer) + gl.enableVertexAttribArray(programInfo.attributeLocation) + gl.vertexAttribPointer( + programInfo.attributeLocation, + 2, + gl.FLOAT, + false, + 0, + 0 + ) + gl.clearColor(0, 0, 0, 0) + gl.clear(gl.COLOR_BUFFER_BIT) + gl.uniform1f( + programInfo.uniforms.angle, + clamp(angleRef.current, MIN_ANGLE, MAX_ANGLE) + ) + gl.uniform1f( + programInfo.uniforms.cellSize, + Math.max(cellSizeRef.current, 1) + ) + gl.uniform2f( + programInfo.uniforms.containerSize, + currentWidth, + currentHeight + ) + gl.uniform1f( + programInfo.uniforms.devicePixelRatio, + currentDevicePixelRatio + ) + gl.uniform4fv(programInfo.uniforms.lineColor, lineColor) + gl.uniform1f( + programInfo.uniforms.time, + reducedMotion.matches ? 0 : timestamp / 1000 + ) + gl.uniform2f( + programInfo.uniforms.viewportSize, + window.innerWidth, + window.innerHeight + ) + gl.drawArrays(gl.TRIANGLES, 0, 3) + } + + const stopAnimation = () => { + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId) + animationFrameId = null + } + } + + const frame = (timestamp: number) => { + draw(timestamp) + + if (!reducedMotion.matches && isVisible) { + animationFrameId = requestAnimationFrame(frame) + return + } + + animationFrameId = null + } + + const syncScene = () => { + if (isContextLost) { + stopAnimation() + setIsWebGlReady(false) + return + } + + if (!gl || !positionBuffer || !programInfo) { + if (!initializePipeline()) { + stopAnimation() + setIsWebGlReady(false) + return + } + } + + resizeCanvas() + + if (currentWidth === 0 || currentHeight === 0) { + stopAnimation() + return + } + + updateLineColor() + draw(performance.now()) + setIsWebGlReady(true) + + if (reducedMotion.matches || !isVisible) { + stopAnimation() + return + } + + if (animationFrameId === null) { + animationFrameId = requestAnimationFrame(frame) + } + } + + syncSceneRef.current = syncScene + + const resizeObserver = new ResizeObserver(() => { + syncScene() + }) + resizeObserver.observe(container) + + const handleWindowResize = () => { + syncScene() + } + + const intersectionObserver = new IntersectionObserver(([entry]) => { + isVisible = entry?.isIntersecting ?? false + + if (isVisible) { + syncScene() + return + } + + stopAnimation() + }) + intersectionObserver.observe(container) + + const themeObserver = new MutationObserver(() => { + syncScene() + }) + themeObserver.observe(document.documentElement, { + attributeFilter: ["class"], + attributes: true, + }) + + const handleMotionChange = () => { + syncScene() + } + + const handleColorSchemeChange = () => { + syncScene() + } + + const handleContextLost = (event: Event) => { + event.preventDefault() + isContextLost = true + stopAnimation() + releasePipeline(false) + setIsWebGlReady(false) + } + + const handleContextRestored = () => { + isContextLost = false + syncScene() + } + + reducedMotion.addEventListener("change", handleMotionChange) + colorScheme.addEventListener("change", handleColorSchemeChange) + window.addEventListener("resize", handleWindowResize) + canvas.addEventListener("webglcontextlost", handleContextLost) + canvas.addEventListener("webglcontextrestored", handleContextRestored) + + syncScene() + + return () => { + stopAnimation() + resizeObserver.disconnect() + intersectionObserver.disconnect() + themeObserver.disconnect() + reducedMotion.removeEventListener("change", handleMotionChange) + colorScheme.removeEventListener("change", handleColorSchemeChange) + window.removeEventListener("resize", handleWindowResize) + canvas.removeEventListener("webglcontextlost", handleContextLost) + canvas.removeEventListener("webglcontextrestored", handleContextRestored) + syncSceneRef.current = null + releasePipeline(!isContextLost) + } + }, []) + const gridStyles = { - "--grid-angle": `${angle}deg`, - "--cell-size": `${cellSize}px`, - "--opacity": opacity, - "--light-line": lightLineColor, - "--dark-line": darkLineColor, - } as React.CSSProperties + ...style, + opacity, + } as CSSProperties + const normalizedAngle = clamp(angle, MIN_ANGLE, MAX_ANGLE) + const normalizedCellSize = Math.max(cellSize, 1) + const fallbackProjectionStyles = { + perspective: `${PERSPECTIVE_PX}px`, + } as CSSProperties + const fallbackRotationStyles = { + transform: `rotateX(${normalizedAngle}deg)`, + } as CSSProperties + const lightFallbackGridStyles = createFallbackGridStyle( + normalizedCellSize, + lightLineColor + ) + const darkFallbackGridStyles = createFallbackGridStyle( + normalizedCellSize, + darkLineColor + ) return (
-
-
-
- + + {!isWebGlReady ? ( +
+
+
+
+
+
+ ) : null} +
) diff --git a/apps/www/registry/registry-ui.ts b/apps/www/registry/registry-ui.ts index 9f30e55c6..fb00913d7 100644 --- a/apps/www/registry/registry-ui.ts +++ b/apps/www/registry/registry-ui.ts @@ -537,17 +537,6 @@ export const ui: Registry["items"] = [ type: "registry:ui", }, ], - cssVars: { - theme: { - "animate-grid": "grid 15s linear infinite", - }, - }, - css: { - "@keyframes grid": { - "0%": { transform: "translateY(-50%)" }, - "100%": { transform: "translateY(0)" }, - }, - }, }, { name: "animated-list", diff --git a/apps/www/styles/globals.css b/apps/www/styles/globals.css index f6a513e81..e4d249b9f 100644 --- a/apps/www/styles/globals.css +++ b/apps/www/styles/globals.css @@ -152,7 +152,6 @@ --animate-accordion-up: accordion-up 0.2s ease-out; --animate-gradient: gradient 8s linear infinite; --animate-meteor: meteor 5s linear infinite; - --animate-grid: grid 15s linear infinite; --animate-marquee: marquee var(--duration) infinite linear; --animate-marquee-vertical: marquee-vertical var(--duration) linear infinite; --animate-spin-around: spin-around calc(var(--speed) * 2) infinite linear; @@ -258,15 +257,6 @@ } } - @keyframes grid { - 0% { - transform: translateY(-50%); - } - 100% { - transform: translateY(0); - } - } - @keyframes marquee { from { transform: translateX(0);