Composants/Composants de base
Dream Sky
Fond WebGL onirique, avec ciel dégradé, nuages, cercles concentriques et traînées prismatiques.
BetaWhoa UI est en bêta : certains éléments peuvent encore changer. Un bug ? Écrivez-moi à hello@ugolin-olle.com.
../../apps/web/examples/dream-sky.tsx
import { DreamSky } from '@/components/ui/ugolin/dream-sky';export default function DreamSkyExample() { return <DreamSky className="size-full" />;}Installation#
Via shadcn — copie le code source
$pnpm dlx shadcn@latest add https://ugolin-olle.com/r/dream-sky.jsonInstallation manuelle
../dream-sky/index.tsx
'use client';import * as React from 'react';import { cn } from '@/lib/utils';type DreamSkyProps = React.ComponentProps<'div'> & { /** Sky colour at the top of the frame. */ skyTop?: string; /** Sky colour at the horizon (bottom of the frame). */ skyBottom?: string; /** Colour of the lit parts of the clouds. */ cloudColor?: string; /** Animation speed multiplier. `0` freezes the sky. */ speed?: number; /** Strength of the prismatic light streaks, `0` to `2`. */ flareIntensity?: number; /** How much of the frame the clouds cover, `0` to `1`. */ cloudDensity?: number; /** Strength of the thin concentric circles, `0` to `2`. */ ringIntensity?: number; /** Upper bound for the canvas pixel ratio. The sky is soft, 1.5 is plenty. */ maxPixelRatio?: number; /** Stops the render loop (the last frame stays on screen). */ paused?: boolean;};type Uniforms = Record< 'time' | 'res' | 'top' | 'bottom' | 'cloud' | 'density' | 'flare' | 'rings', WebGLUniformLocation | null>;/** Time shown when the sky is static (reduced motion, no animation): a balanced cloud layout. */const STATIC_TIME = 18;const VERTEX = `attribute vec2 aPos;void main() { gl_Position = vec4(aPos, 0.0, 1.0); }`;const FRAGMENT = `#ifdef GL_FRAGMENT_PRECISION_HIGHprecision highp float;#elseprecision mediump float;#endifuniform vec2 uRes;uniform float uTime;uniform vec3 uTop;uniform vec3 uBottom;uniform vec3 uCloud;uniform float uDensity;uniform float uFlare;uniform float uRings;float hash(vec2 p) { p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y);}float noise(vec2 p) { vec2 i = floor(p); vec2 f = fract(p); f = f * f * (3.0 - 2.0 * f); return mix( mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x), mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), f.x), f.y );}float fbm(vec2 p) { float v = 0.0; float a = 0.5; mat2 m = mat2(1.6, 1.2, -1.2, 1.6); for (int i = 0; i < 5; i++) { v += a * noise(p); p = m * p; a *= 0.5; } return v;}void main() { vec2 uv = gl_FragCoord.xy / uRes; float asp = uRes.x / uRes.y; vec2 p = (uv - 0.5) * vec2(asp, 1.0); float t = uTime; // Sky: vertical gradient plus a soft glow behind the centre of the frame. vec3 col = mix(uBottom, uTop, smoothstep(0.0, 1.0, uv.y)); col += vec3(0.07, 0.09, 0.1) * exp(-length(p - vec2(0.0, 0.05)) * 2.2); // Concentric circles: thin lines that breathe and slowly turn (angular modulation). float r = length(p); float ang = atan(p.y, p.x); float cycles = 7.0; float w = 0.75 / uRes.y * cycles; float d = abs(fract(r * cycles - t * 0.05) - 0.5); float line = smoothstep(0.5 - 2.0 * w, 0.5 - w, d); float turn = 0.55 + 0.45 * sin(ang * 5.0 + t * 0.12 + r * 3.0); float pulse = 0.75 + 0.25 * sin(t * 0.4 - r * 4.0); float ringFade = smoothstep(1.3, 0.25, r) * smoothstep(0.02, 0.22, r); col += line * turn * pulse * ringFade * 0.2 * uRings; // Prismatic streaks: elongated glows whose hue drifts along their length and over time. vec3 flare = vec3(0.0); for (int i = 0; i < 5; i++) { float fi = float(i); vec2 c = vec2( asp * 0.45 * sin(t * 0.05 * (1.0 + fi * 0.3) + fi * 2.1), 0.42 * sin(t * 0.043 + fi * 1.7 + 1.3) ); float a = -0.35 + 0.4 * sin(fi * 1.3 + t * 0.03); vec2 dir = vec2(cos(a), sin(a)); vec2 dd = p - c; float along = dot(dd, dir); float perp = dot(dd, vec2(-dir.y, dir.x)); float len = 0.22 + 0.08 * mod(fi, 3.0); float wid = 0.026 + 0.008 * mod(fi, 2.0); float s = exp(-pow(along / len, 2.0) - pow(perp / wid, 2.0)); float h = fract(0.55 + fi * 0.17 + t * 0.012 + along * 1.2); vec3 rgb = 0.5 + 0.5 * cos(6.2832 * (h + vec3(0.0, 0.33, 0.67))); rgb = mix(vec3(1.0), rgb, 0.85); flare += rgb * s * (0.55 + 0.45 * sin(t * 0.2 + fi * 1.9)); } // Clouds: domain-warped fbm, pushed towards the bottom and side edges so the centre stays clear. vec2 q = vec2(p.x * 1.4 + t * 0.03, p.y * 1.8 - t * 0.004); vec2 warp = vec2(fbm(q + vec2(0.0, t * 0.02)), fbm(q + vec2(5.2, 1.3) - t * 0.015)); vec2 cq = q * 1.3 + warp * 0.9; float n = fbm(cq); float nUp = fbm(cq + vec2(0.0, 0.08)); float bottom = smoothstep(0.62, 0.0, uv.y); float side = smoothstep(0.45, 1.0, abs(uv.x - 0.5) * 2.0) * (0.35 + 0.65 * (1.0 - uv.y)); float mask = max(bottom, side * 0.85); float cover = n * 0.85 + mask * 0.5; float thr = 0.85 - 0.4 * uDensity; float cloud = smoothstep(thr, thr + 0.28, cover); float lit = clamp((n - nUp) * 6.0 + 0.5, 0.0, 1.0); vec3 cloudCol = mix(uCloud * vec3(0.8, 0.88, 0.97), uCloud, lit); col = mix(col, cloudCol, cloud * 0.92); // Streaks sit over the clouds, like light catching the lens. col += flare * 0.6 * uFlare; // Dither to hide banding on the smooth gradients. col += (hash(gl_FragCoord.xy + fract(t)) - 0.5) / 255.0; gl_FragColor = vec4(min(col, 1.0), 1.0);}`;function hexToRgb(hex: string): [number, number, number] { let h = hex.trim().replace('#', ''); if (h.length === 3) h = [...h].map((c) => c + c).join(''); const n = Number.parseInt(h, 16); if (Number.isNaN(n) || h.length !== 6) return [1, 1, 1]; return [(n >> 16) / 255, ((n >> 8) & 255) / 255, (n & 255) / 255];}function compile(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)) { gl.deleteShader(shader); return null; } return shader;}function DreamSky({ skyTop = '#0a78ff', skyBottom = '#5ec0ff', cloudColor = '#ffffff', speed = 1, flareIntensity = 1, cloudDensity = 0.55, ringIntensity = 1, maxPixelRatio = 1.5, paused = false, className, children, ...props}: DreamSkyProps) { const containerRef = React.useRef<HTMLDivElement>(null); const canvasRef = React.useRef<HTMLCanvasElement>(null); // The render loop reads the latest props from a ref, so tuning never rebuilds the GL context. const live = { skyTop, skyBottom, cloudColor, speed, flareIntensity, cloudDensity, ringIntensity, }; const liveRef = React.useRef(live); liveRef.current = live; const pausedRef = React.useRef(paused); pausedRef.current = paused; const maxPixelRatioRef = React.useRef(maxPixelRatio); maxPixelRatioRef.current = maxPixelRatio; const syncRef = React.useRef<() => void>(undefined); React.useEffect(() => { const container = containerRef.current; const canvas = canvasRef.current; if (!container || !canvas) return; const gl = canvas.getContext('webgl', { alpha: false, antialias: false, powerPreference: 'low-power', }); if (!gl) return; const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); let program: WebGLProgram | null = null; let buffer: WebGLBuffer | null = null; let uniforms: Uniforms | null = null; let raf = 0; let last = 0; let time = STATIC_TIME; let inView = true; let cssW = 1; let cssH = 1; const init = () => { const vs = compile(gl, gl.VERTEX_SHADER, VERTEX); const fs = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT); program = gl.createProgram(); if (!vs || !fs || !program) return false; gl.attachShader(program, vs); gl.attachShader(program, fs); gl.linkProgram(program); gl.deleteShader(vs); gl.deleteShader(fs); if (!gl.getProgramParameter(program, gl.LINK_STATUS)) return false; gl.useProgram(program); // One oversized triangle covers the viewport. buffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW); const loc = gl.getAttribLocation(program, 'aPos'); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0); const u = (name: string) => gl.getUniformLocation(program!, name); uniforms = { time: u('uTime'), res: u('uRes'), top: u('uTop'), bottom: u('uBottom'), cloud: u('uCloud'), density: u('uDensity'), flare: u('uFlare'), rings: u('uRings'), }; return true; }; const draw = () => { if (!uniforms || gl.isContextLost()) return; const p = liveRef.current; gl.viewport(0, 0, canvas.width, canvas.height); gl.uniform1f(uniforms.time, motionQuery.matches || p.speed === 0 ? STATIC_TIME : time); gl.uniform2f(uniforms.res, canvas.width, canvas.height); gl.uniform3fv(uniforms.top, hexToRgb(p.skyTop)); gl.uniform3fv(uniforms.bottom, hexToRgb(p.skyBottom)); gl.uniform3fv(uniforms.cloud, hexToRgb(p.cloudColor)); gl.uniform1f(uniforms.density, Math.min(Math.max(p.cloudDensity, 0), 1)); gl.uniform1f(uniforms.flare, p.flareIntensity); gl.uniform1f(uniforms.rings, p.ringIntensity); gl.drawArrays(gl.TRIANGLES, 0, 3); canvas.dataset.ready = 'true'; }; const frame = (now: number) => { raf = requestAnimationFrame(frame); // Clamp dt so returning to the tab never makes the sky jump. time += Math.min((now - last) / 1000, 0.1) * liveRef.current.speed; last = now; draw(); }; const resize = () => { const dpr = Math.min(window.devicePixelRatio || 1, maxPixelRatioRef.current); canvas.width = Math.max(1, Math.round(cssW * dpr)); canvas.height = Math.max(1, Math.round(cssH * dpr)); draw(); }; const sync = () => { const active = inView && !document.hidden && !motionQuery.matches && !pausedRef.current && liveRef.current.speed !== 0; if (active && !raf) { last = performance.now(); raf = requestAnimationFrame(frame); } else if (!active) { if (raf) cancelAnimationFrame(raf); raf = 0; draw(); } }; syncRef.current = sync; if (!init()) return; const resizeObserver = new ResizeObserver(([entry]) => { cssW = entry.contentRect.width; cssH = entry.contentRect.height; resize(); }); resizeObserver.observe(container); const intersection = new IntersectionObserver(([entry]) => { inView = entry.isIntersecting; sync(); }); intersection.observe(container); const onLost = (event: Event) => { event.preventDefault(); if (raf) cancelAnimationFrame(raf); raf = 0; canvas.dataset.ready = 'false'; }; const onRestored = () => { if (init()) { resize(); sync(); } }; document.addEventListener('visibilitychange', sync); motionQuery.addEventListener('change', sync); canvas.addEventListener('webglcontextlost', onLost); canvas.addEventListener('webglcontextrestored', onRestored); sync(); return () => { if (raf) cancelAnimationFrame(raf); resizeObserver.disconnect(); intersection.disconnect(); document.removeEventListener('visibilitychange', sync); motionQuery.removeEventListener('change', sync); canvas.removeEventListener('webglcontextlost', onLost); canvas.removeEventListener('webglcontextrestored', onRestored); if (program) gl.deleteProgram(program); if (buffer) gl.deleteBuffer(buffer); canvas.dataset.ready = 'false'; syncRef.current = undefined; }; }, []); // Prop changes: restart/stop the loop (paused, speed) and repaint a still sky. React.useEffect(() => { syncRef.current?.(); }, [paused, speed, skyTop, skyBottom, cloudColor, flareIntensity, cloudDensity, ringIntensity]); return ( <div ref={containerRef} className={cn('relative isolate overflow-hidden', className)} {...props} > {/* CSS fallback: visible until the first frame, and when WebGL is unavailable. */} <div aria-hidden className="absolute inset-0 -z-10" style={{ background: `linear-gradient(to top, ${skyBottom}, ${skyTop})` }} /> <canvas ref={canvasRef} aria-hidden data-ready="false" className="pointer-events-none absolute inset-0 -z-10 size-full opacity-0 transition-opacity duration-500 ease-out data-[ready=true]:opacity-100 motion-reduce:transition-none" /> {children} </div> );}export { DreamSky };export type { DreamSkyProps };Un fond animé en un seul fragment shader WebGL, sans Three.js ni dépendance : react suffit.
Il remplit son conteneur et affiche ses children par-dessus.
Props
| Prop | Défaut | Rôle |
|---|---|---|
skyTop | #0a78ff | Couleur du haut du ciel |
skyBottom | #5ec0ff | Couleur de l'horizon |
cloudColor | #ffffff | Couleur des nuages éclairés |
speed | 1 | Multiplicateur de vitesse (0 fige le ciel) |
flareIntensity | 1 | Force des traînées prismatiques (0 à 2) |
cloudDensity | 0.55 | Couverture nuageuse (0 à 1) |
ringIntensity | 1 | Force des cercles concentriques (0 à 2) |
maxPixelRatio | 1.5 | Plafond du pixel ratio du canvas |
paused | false | Arrête la boucle de rendu |
Comportement
- La boucle est en pause quand le composant est hors écran ou l'onglet masqué.
- Avec
prefers-reduced-motion, le ciel est rendu une fois, sans animation. - Sans WebGL, un dégradé CSS prend le relais.