Write a single self-contained HTML file …
This is a complete HTML document that creates an animated night sky scene with twinkling stars, a crescent moon, and dynamic shooting stars that streak across the canvas.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Night Sky - Shooting Stars</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
width: 100vw;
height: 100vh;
overflow: hidden;
background: #050510;
font-family: 'Georgia', serif;
}
canvas {
display: block;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.hint {
position: fixed;
bottom: 30px;
width: 100%;
text-align: center;
color: rgba(255, 255, 255, 0.4);
font-size: 14px;
letter-spacing: 2px;
pointer-events: none;
z-index: 10;
animation: fadeHint 4s ease-in-out infinite;
}
@keyframes fadeHint {
0%,
100% {
opacity: 0.3;
}
50% {
opacity: 0.7;
}
}
</style>
</head>
<body>
<canvas id="skyCanvas"></canvas>
<div class="hint">✨ look up ✨</div>
<script>
(function() {
const canvas = document.getElementById('skyCanvas');
const ctx = canvas.getContext('2d');
let width, height;
let backgroundCanvas;
let backgroundCtx;
// --- Configuration ---
const STAR_COUNT = 350;
const TWINKLE_STAR_COUNT = 80;
const SHOOTING_STAR_MIN_INTERVAL = 1800; // ms
const SHOOTING_STAR_MAX_INTERVAL = 5000; // ms
const MAX_SHOOTING_STARS = 3;
const TRAIL_PARTICLE_LIFETIME = 1.5; // seconds
// --- Data ---
let stars = [];
let twinkleStars = [];
let shootingStars = [];
let trailParticles = [];
let spawnTimer = 0;
let lastTime = performance.now();
let timeAccum = 0;
// --- Color presets for shooting stars ---
const shootingStarColors = [
{ r: 255, g: 255, b: 255, name: 'pure white' },
{ r: 255, g: 250, b: 240, name: 'warm white' },
{ r: 220, g: 235, b: 255, name: 'cool blue-white' },
{ r: 255, g: 240, b: 200, name: 'golden' },
{ r: 200, g: 240, b: 255, name: 'icy blue' },
{ r: 255, g: 220, b: 180, name: 'peach' },
{ r: 180, g: 255, b: 230, name: 'mint' },
];
// --- Resize Handler ---
function resize() {
width = window.innerWidth;
height = window.innerHeight;
canvas.width = width;
canvas.height = height;
// Recreate background canvas
backgroundCanvas = document.createElement('canvas');
backgroundCanvas.width = width;
backgroundCanvas.height = height;
backgroundCtx = backgroundCanvas.getContext('2d');
renderBackground();
createStars();
}
// --- Render Static Background ---
function renderBackground() {
const ctx = backgroundCtx;
const w = width;
const h = height;
// Sky gradient
const skyGradient = ctx.createLinearGradient(0, 0, 0, h);
skyGradient.addColorStop(0, '#0a0a2e');
skyGradient.addColorStop(0.25, '#0d1035');
skyGradient.addColorStop(0.5, '#111840');
skyGradient.addColorStop(0.75, '#0e1538');
skyGradient.addColorStop(1, '#0a0f2c');
ctx.fillStyle = skyGradient;
ctx.fillRect(0, 0, w, h);
// Subtle nebula glows
const nebulae = [
{ x: w * 0.25, y: h * 0.2, r: Math.min(w, h) * 0.5, color: 'rgba(30, 20, 80, 0.08)' },
{ x: w * 0.7, y: h * 0.35, r: Math.min(w, h) * 0.4, color: 'rgba(20, 30, 70, 0.06)' },
{ x: w * 0.5, y: h * 0.15, r: Math.min(w, h) * 0.55, color: 'rgba(40, 15, 60, 0.05)' },
{ x: w * 0.15, y: h * 0.5, r: Math.min(w, h) * 0.35, color: 'rgba(15, 25, 55, 0.07)' },
{ x: w * 0.8, y: h * 0.55, r: Math.min(w, h) * 0.3, color: 'rgba(25, 20, 50, 0.06)' },
];
nebulae.forEach(n => {
const gradient = ctx.createRadialGradient(n.x, n.y, 0, n.x, n.y, n.r);
gradient.addColorStop(0, n.color);
gradient.addColorStop(0.5, n.color.replace(/[\d.]+\)$/, '0.03)'));
gradient.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, w, h);
});
// Crescent moon
drawMoon(ctx, w * 0.78, h * 0.18, Math.min(w, h) * 0.06);
// Subtle ground silhouette
drawGroundSilhouette(ctx, w, h);
// Static stars (drawn once on background)
for (let i = 0; i < STAR_COUNT - TWINKLE_STAR_COUNT; i++) {
const sx = Math.random() * w;
const sy = Math.random() * h * 0.85;
const sr = 0.3 + Math.random() * 1.4;
const alpha = 0.2 + Math.random() * 0.7;
ctx.fillStyle = `rgba(255,255,255,${alpha})`;
ctx.beginPath();
ctx.arc(sx, sy, sr, 0, Math.PI * 2);
ctx.fill();
// Occasional cross-sparkle for brighter stars
if (sr > 1.0 && Math.random() < 0.3) {
const sparkleAlpha = alpha * 0.5;
ctx.strokeStyle = `rgba(255,255,255,${sparkleAlpha})`;
ctx.lineWidth = 0.3;
ctx.beginPath();
ctx.moveTo(sx - sr * 3, sy);
ctx.lineTo(sx + sr * 3, sy);
ctx.moveTo(sx, sy - sr * 3);
ctx.lineTo(sx, sy + sr * 3);
ctx.stroke();
}
}
}
function drawMoon(ctx, mx, my, mr) {
// Moon glow
const glowGradient = ctx.createRadialGradient(mx, my, mr * 0.5, mx, my, mr * 2.5);
glowGradient.addColorStop(0, 'rgba(255,250,235,0.25)');
glowGradient.addColorStop(0.4, 'rgba(255,250,235,0.1)');
glowGradient.addColorStop(1, 'rgba(255,250,235,0)');
ctx.fillStyle = glowGradient;
ctx.beginPath();
ctx.arc(mx, my, mr * 2.5, 0, Math.PI * 2);
ctx.fill();
// Crescent moon using two arcs with opposite winding
ctx.fillStyle = '#fdf5e6';
ctx.beginPath();
ctx.arc(mx, my, mr, 0, Math.PI * 2, false); // outer circle clockwise
const cutX = mx + mr * 0.45;
const cutY = my - mr * 0.25;
const cutR = mr * 0.88;
ctx.arc(cutX, cutY, cutR, 0, Math.PI * 2, true); // inner circle counter-clockwise (hole)
ctx.closePath();
ctx.fill();
// Subtle inner glow on the crescent
const innerGlow = ctx.createRadialGradient(mx - mr * 0.2, my - mr * 0.1, mr * 0.1, mx, my, mr);
innerGlow.addColorStop(0, 'rgba(255,255,255,0.3)');
innerGlow.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = innerGlow;
ctx.beginPath();
ctx.arc(mx, my, mr, 0, Math.PI * 2, false);
ctx.arc(cutX, cutY, cutR, 0, Math.PI * 2, true);
ctx.closePath();
ctx.fill();
}
function drawGroundSilhouette(ctx, w, h) {
// Very subtle dark hills at the bottom
ctx.fillStyle = 'rgba(3,3,15,0.85)';
ctx.beginPath();
ctx.moveTo(0, h);
for (let x = 0; x <= w; x += w / 40) {
const hillHeight = h * 0.06 + Math.sin(x * 0.008) * h * 0.04 +
Math.sin(x * 0.02 + 1.5) * h * 0.03 +
Math.sin(x * 0.005 + 3) * h * 0.05;
ctx.lineTo(x, h - hillHeight);
}
ctx.lineTo(w, h);
ctx.closePath();
ctx.fill();
// Slightly lighter distant hill layer
ctx.fillStyle = 'rgba(5,5,20,0.7)';
ctx.beginPath();
ctx.moveTo(0, h);
for (let x = 0; x <= w; x += w / 35) {
const hillHeight = h * 0.03 + Math.sin(x * 0.01 + 1) * h * 0.025 +
Math.sin(x * 0.025 + 2) * h * 0.02;
ctx.lineTo(x, h - hillHeight);
}
ctx.lineTo(w, h);
ctx.closePath();
ctx.fill();
}
// --- Create Stars ---
function createStars() {
stars = [];
twinkleStars = [];
// Twinkling stars (animated)
for (let i = 0; i < TWINKLE_STAR_COUNT; i++) {
twinkleStars.push({
x: Math.random() * width,
y: Math.random() * height * 0.85,
radius: 0.5 + Math.random() * 2.0,
baseAlpha: 0.3 + Math.random() * 0.7,
twinkleSpeed: 0.5 + Math.random() * 3.0,
twinklePhase: Math.random() * Math.PI * 2,
hasCrossSparkle: Math.random() < 0.25,
});
}
// Some additional brighter static stars for the animated layer
for (let i = 0; i < 30; i++) {
stars.push({
x: Math.random() * width,
y: Math.random() * height * 0.85,
radius: 0.3 + Math.random() * 1.0,
alpha: 0.3 + Math.random() * 0.5,
});
}
}
// --- Shooting Star Class ---
class ShootingStar {
constructor() {
this.reset();
}
reset() {
// Choose starting position
const side = Math.random();
if (side < 0.55) {
// Start from top region
this.x = Math.random() * width;
this.y = Math.random() * height * 0.25;
} else if (side < 0.8) {
// Start from upper left
this.x = Math.random() * width * 0.25;
this.y = Math.random() * height * 0.4;
} else {
// Start from upper right
this.x = width - Math.random() * width * 0.25;
this.y = Math.random() * height * 0.4;
}
// Angle: mostly diagonal downward
const baseAngle = Math.PI * 0.35; // ~63° from horizontal
this.angle = baseAngle + (Math.random() - 0.5) * Math.PI * 0.35;
// Ensure it goes somewhat downward
if (this.angle < 0.15) this.angle = 0.15;
if (this.angle > Math.PI * 0.7) this.angle = Math.PI * 0.7;
this.speed = 3.5 + Math.random() * 9; // pixels per frame at 60fps-ish
this.trail = [];
this.maxTrailLength = 18 + Math.floor(Math.random() * 35);
this.opacity = 0.7 + Math.random() * 0.3;
this.width = 0.8 + Math.random() * 2.5;
this.active = true;
this.age = 0;
this.maxAge = 1.2 + Math.random() * 2.5; // seconds before fading
this.sparkRate = 0.3 + Math.random() * 0.7; // sparks per frame
this.sparkAccum = 0;
// Color preset
const colorPreset = shootingStarColors[
Math.floor(Math.random() * shootingStarColors.length)
];
this.r = colorPreset.r;
this.g = colorPreset.g;
this.b = colorPreset.b;
}
update(dt) {
this.age += dt;
if (this.age > this.maxAge) {
// Fade out the trail into particles
this.releaseTrailParticles();
this.active = false;
return;
}
// Add current position to trail
this.trail.push({
x: this.x,
y: this.y,
age: 0,
});
// Move
const speedMultiplier = 1 + this.age * 0.3; // slight acceleration
this.x += Math.cos(this.angle) * this.speed * speedMultiplier * dt * 60;
this.y += Math.sin(this.angle) * this.speed * speedMultiplier * dt * 60;
// Update trail ages
for (const point of this.trail) {
point.age += dt;
}
// Trim old trail points
const maxTrailAge = 0.6 + (this.maxTrailLength / 30) * 0.8;
while (this.trail.length > 0 && this.trail[0].age > maxTrailAge) {
this.trail.shift();
}
// Also trim by length
while (this.trail.length > this.maxTrailLength) {
this.trail.shift();
}
// Emit spark particles
this.sparkAccum += this.sparkRate * dt * 60;
while (this.sparkAccum >= 1 && this.trail.length > 0) {
this.sparkAccum -= 1;
const refPoint = this.trail[this.trail.length - 1];
this.emitSpark(refPoint.x, refPoint.y);
}
// Check bounds
const margin = 60;
if (
this.x > width + margin ||
this.y > height + margin ||
this.x < -margin ||
this.y < -margin
) {
this.releaseTrailParticles();
this.active = false;
}
}
emitSpark(x, y) {
const perpAngle = this.angle + Math.PI / 2;
const spread = (Math.random() - 0.5) * 1.5;
const sparkAngle = perpAngle + spread;
const sparkSpeed = 0.3 + Math.random() * 1.8;
trailParticles.push({
x: x + (Math.random() - 0.5) * 3,
y: y + (Math.random() - 0.5) * 3,
vx: Math.cos(sparkAngle) * sparkSpeed,
vy: Math.sin(sparkAngle) * sparkSpeed,
alpha: 0.5 + Math.random() * 0.5,
size: 0.3 + Math.random() * 1.2,
life: TRAIL_PARTICLE_LIFETIME,
maxLife: TRAIL_PARTICLE_LIFETIME,
r: this.r,
g: this.g,
b: this.b,
});
}
releaseTrailParticles() {
for (const point of this.trail) {
if (point.age < 0.5) {
trailParticles.push({
x: point.x + (Math.random() - 0.5) * 4,
y: point.y + (Math.random() - 0.5) * 4,
vx: (Math.random() - 0.5) * 0.6,
vy: (Math.random() - 0.5) * 0.6 + 0.2,
alpha: 0.3 + Math.random() * 0.5,
size: 0.2 + Math.random() * 1.0,
life: 0.4 + Math.random() * 1.0,
maxLife: 1.4,
r: this.r,
g: this.g,
b: this.b,
});
}
}
}
draw(ctx) {
if (this.trail.length < 2) {
// Just draw a dot if very short trail
if (this.trail.length === 1) {
const p = this.trail[0];
const gradient = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, this.width * 3);
gradient.addColorStop(0,
`rgba(${this.r},${this.g},${this.b},${this.opacity})`);
gradient.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(p.x, p.y, this.width * 3, 0, Math.PI * 2);
ctx.fill();
}
return;
}
// Draw trail as connected segments with varying opacity
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
for (let i = 0; i < this.trail.length - 1; i++) {
const progress = i / (this.trail.length - 1);
const alpha = progress * progress * this.opacity;
const lineWidth = progress * this.width + 0.3;
ctx.strokeStyle =
`rgba(${this.r},${this.g},${this.b},${alpha})`;
ctx.lineWidth = lineWidth;
ctx.beginPath();
ctx.moveTo(this.trail[i].x, this.trail[i].y);
ctx.lineTo(this.trail[i + 1].x, this.trail[i + 1].y);
ctx.stroke();
}
// Draw bright head glow
const head = this.trail[this.trail.length - 1];
const glowRadius = this.width * 4 + 2;
const glowGradient = ctx.createRadialGradient(
head.x, head.y, 0,
head.x, head.y, glowRadius
);
glowGradient.addColorStop(0,
`rgba(${this.r},${this.g},${this.b},${this.opacity})`);
glowGradient.addColorStop(0.15,
`rgba(${this.r},${this.g},${this.b},${this.opacity * 0.7})`);
glowGradient.addColorStop(0.5,
`rgba(${this.r},${this.g},${this.b},${this.opacity * 0.2})`);
glowGradient.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = glowGradient;
ctx.beginPath();
ctx.arc(head.x, head.y, glowRadius, 0, Math.PI * 2);
ctx.fill();
// Tiny bright core
ctx.fillStyle =
`rgba(${this.r},${this.g},${this.b},${Math.min(1, this.opacity + 0.2)})`;
ctx.beginPath();
ctx.arc(head.x, head.y, this.width * 0.8, 0, Math.PI * 2);
ctx.fill();
}
}
// --- Spawn Shooting Star ---
function spawnShootingStar() {
if (shootingStars.length < MAX_SHOOTING_STARS) {
shootingStars.push(new ShootingStar());
}
// Schedule next spawn
const interval = SHOOTING_STAR_MIN_INTERVAL +
Math.random() * (SHOOTING_STAR_MAX_INTERVAL - SHOOTING_STAR_MIN_INTERVAL);
spawnTimer = interval;
// Occasionally spawn a cluster
if (Math.random() < 0.2) {
const clusterDelay = 200 + Math.random() * 600;
setTimeout(() => {
if (shootingStars.length < MAX_SHOOTING_STARS) {
shootingStars.push(new ShootingStar());
}
// Maybe a third one
if (Math.random() < 0.4) {
setTimeout(() => {
if (shootingStars.length < MAX_SHOOTING_STARS) {
shootingStars.push(new ShootingStar());
}
}, 150 + Math.random() * 400);
}
}, clusterDelay);
}
}
// --- Update Trail Particles ---
function updateTrailParticles(dt) {
for (let i = trailParticles.length - 1; i >= 0; i--) {
const p = trailParticles[i];
p.life -= dt;
if (p.life <= 0) {
trailParticles.splice(i, 1);
continue;
}
p.x += p.vx * dt * 60;
p.y += p.vy * dt * 60;
p.vy += 0.015 * dt * 60; // slight gravity
p.alpha = (p.life / p.maxLife) * p.alpha;
}
}
// --- Draw Trail Particles ---
function drawTrailParticles(ctx) {
for (const p of trailParticles) {
const alpha = p.alpha * (p.life / p.maxLife);
if (alpha < 0.01) continue;
const gradient = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size * 2);
gradient.addColorStop(0, `rgba(${p.r},${p.g},${p.b},${alpha})`);
gradient.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(p.x, p.y, p.size * 2, 0, Math.PI * 2);
ctx.fill();
}
}
// --- Draw Twinkling Stars ---
function drawTwinklingStars(ctx, time) {
for (const star of twinkleStars) {
const twinkle = 0.5 + 0.5 * Math.sin(time * star.twinkleSpeed + star.twinklePhase);
const alpha = star.baseAlpha * (0.35 + 0.65 * twinkle);
const radius = star.radius * (0.85 + 0.15 * twinkle);
// Glow
const glowGradient = ctx.createRadialGradient(star.x, star.y, 0, star.x, star.y, radius * 3);
glowGradient.addColorStop(0, `rgba(255,255,255,${alpha})`);
glowGradient.addColorStop(0.5, `rgba(255,255,255,${alpha * 0.2})`);
glowGradient.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = glowGradient;
ctx.beginPath();
ctx.arc(star.x, star.y, radius * 3, 0, Math.PI * 2);
ctx.fill();
// Core
ctx.fillStyle = `rgba(255,255,255,${alpha})`;
ctx.beginPath();
ctx.arc(star.x, star.y, radius, 0, Math.PI * 2);
ctx.fill();
// Cross sparkle for some stars
if (star.hasCrossSparkle && twinkle > 0.75) {
const sparkAlpha = alpha * (twinkle - 0.75) * 3;
ctx.strokeStyle = `rgba(255,255,255,${sparkAlpha})`;
ctx.lineWidth = 0.4;
const sparkLen = radius * 5;
ctx.beginPath();
ctx.moveTo(star.x - sparkLen, star.y);
ctx.lineTo(star.x + sparkLen, star.y);
ctx.moveTo(star.x, star.y - sparkLen);
ctx.lineTo(star.x, star.y + sparkLen);
ctx.stroke();
}
}
// Additional static bright stars
for (const star of stars) {
ctx.fillStyle = `rgba(255,255,255,${star.alpha})`;
ctx.beginPath();
ctx.arc(star.x, star.y, star.radius, 0, Math.PI * 2);
ctx.fill();
}
}
// --- Main Animation Loop ---
function animate(timestamp) {
let dt = (timestamp - lastTime) / 1000;
lastTime = timestamp;
// Clamp dt to avoid jumps
if (dt > 0.2) dt = 0.2;
if (dt <= 0) dt = 0.016;
timeAccum += dt;
// Update spawn timer
spawnTimer -= dt * 1000;
if (spawnTimer <= 0) {
spawnShootingStar();
}
// Update shooting stars
for (let i = shootingStars.length - 1; i >= 0; i--) {
shootingStars[i].update(dt);
if (!shootingStars[i].active) {
shootingStars.splice(i, 1);
}
}
// Update trail particles
updateTrailParticles(dt);
// --- Draw Frame ---
// Draw static background
ctx.drawImage(backgroundCanvas, 0, 0);
// Draw trail particles (behind shooting stars)
drawTrailParticles(ctx);
// Draw shooting stars
for (const ss of shootingStars) {
ss.draw(ctx);
}
// Draw twinkling stars on top
drawTwinklingStars(ctx, timeAccum);
// Subtle vignette
const vignetteGradient = ctx.createRadialGradient(
width / 2, height / 2, Math.min(width, height) * 0.55,
width / 2, height / 2, Math.max(width, height) * 0.75
);
vignetteGradient.addColorStop(0, 'rgba(0,0,0,0)');
vignetteGradient.addColorStop(1, 'rgba(0,0,0,0.35)');
ctx.fillStyle = vignetteGradient;
ctx.fillRect(0, 0, width, height);
requestAnimationFrame(animate);
}
// --- Initialize ---
function init() {
resize();
// Initial spawn
spawnTimer = 500 + Math.random() * 2000;
// Spawn one immediately for instant gratification
shootingStars.push(new ShootingStar());
lastTime = performance.now();
requestAnimationFrame(animate);
}
// --- Event Listeners ---
window.addEventListener('resize', () => {
resize();
// Recreate twinkle stars on resize
createStars();
});
// Click to spawn a shooting star
window.addEventListener('click', (e) => {
const star = new ShootingStar();
// Bias toward the click location
if (e.clientX && e.clientY) {
star.x = e.clientX + (Math.random() - 0.5) * 200;
star.y = Math.max(10, e.clientY - 100 + (Math.random() - 0.5) * 150);
star.angle = Math.PI * 0.3 + Math.random() * Math.PI * 0.4;
}
shootingStars.push(star);
// Trim if too many
while (shootingStars.length > MAX_SHOOTING_STARS + 2) {
shootingStars.shift();
}
});
// Touch support
window.addEventListener('touchstart', (e) => {
const touch = e.touches[0];
if (touch) {
const star = new ShootingStar();
star.x = touch.clientX + (Math.random() - 0.5) * 200;
star.y = Math.max(10, touch.clientY - 100 + (Math.random() - 0.5) * 150);
star.angle = Math.PI * 0.3 + Math.random() * Math.PI * 0.4;
shootingStars.push(star);
while (shootingStars.length > MAX_SHOOTING_STARS + 2) {
shootingStars.shift();
}
}
});
// --- Start ---
init();
console.log('🌌 Night sky ready. Click anywhere to summon a shooting star!');
})();
</script>
</body>
</html>
Night Sky Features
Here’s how the page brings the night sky to life, focusing on the visual effects and how you can interact with them.
-
Dynamic Celestial Objects: The scene includes a crescent moon with a soft glow, hundreds of static stars, and a set of twinkling stars that pulse in brightness. A subtle, dark ground silhouette at the bottom adds depth to the landscape.
-
Shooting Star Mechanics: Shooting stars appear automatically at random intervals. Each one has a unique color tint (from pure white to icy blue or golden), a glowing head, and a fading trail. As they streak across the sky, they emit tiny spark particles that drift and fade, creating a realistic, magical effect.
-
Interactive Element: You can click or tap anywhere on the screen to instantly summon a new shooting star near that location. This adds a playful, responsive layer to the passive viewing experience.
-
Visual Atmosphere: The background features a deep, gradient night sky with subtle nebula-like glows. A soft vignette effect darkens the edges of the screen, focusing your attention on the center and enhancing the immersive, dreamy mood.
0 comments
Log in to join the conversation.