Synthwave sunset · seed-2.0-mini
Create an animated synthwave sunset: a glowing gradient sun with scanlines setting over an infinite neon grid that scrolls toward the viewer, with a starfield and a reflected horizon.
transcript · 6 messages
Create an animated synthwave sunset: a glowing gradient sun with scanlines setting over an infinite neon grid that scrolls toward the viewer, with a starfield and a reflected horizon.
thinking
Got it, let's tackle this. The user wants a single-file self-contained synthwave sunset web app. First, I need to use HTML5 Canvas for all the animation, no external resources.
First, start with the basic HTML structure: a fullscreen canvas, style it to cover the screen, no margins, etc. Then the JS will handle the animation loop.
Let's break down the components:
1. Glowing gradient sun: needs a radial gradient, maybe with some pulse animation, color like orange/pink/blue synthwave tones.
2. Infinite neon grid: scrolls toward the viewer, so we can have a grid that repeats by offsetting the Y (and maybe X?) position, draw lines at intervals, make them neon cyan/purple. Also scanlines over the whole screen—thin horizontal black lines with some opacity, scrolling or static? Wait, synthwave scanlines are usually static semi-transparent bars.
3. Starfield: random stars, maybe twinkling, moving slowly as well? Or fixed but some fade in/out.
4. Reflected horizon: the grid and sun reflected below the horizon line, maybe with a fade/opacity to look like water reflection.
5. Scroll speed: the grid should move down? Wait no, toward the viewer, so if it's a grid that's below the horizon, moving up? Wait no, when you're moving toward something, the background scrolls past you, so the grid lines should move from the bottom to top? Wait no, wait, let's think: the sun is setting over the horizon, so the horizon is a horizontal line across the middle? No, wait sunset: sun is going down below the horizon, so horizon is at, say, 60% of the screen height. The grid is the ground, so it's below the horizon, scrolling upward (so it looks like we're driving forward on the grid). Wait no, if the grid is scrolling toward the viewer, then each frame we shift the grid's position, and when a line goes off the top, we wrap it to the bottom? Wait no, better to have the grid tiles repeat, so we can have a offset that increases each frame, and draw multiple copies of the grid to make it infinite.
Wait let's outline the steps in code:
First, set up the canvas: full width and height, resize when window changes.
Then, define variables:
- Canvas context, width, height
- Grid parameters: line spacing, color, scroll speed
- Sun parameters: position (x at center, y starting at some point, moving down toward horizon), radius, gradient colors
- Starfield: array of star objects with x, y, size, brightness, twinkle speed
- Scanline parameters: opacity, line height
- Horizon line position: let's say h = canvas.height * 0.6, so below that is the grid/ground, above is sky? Wait no, sunset: sky is above horizon, sun is setting towards horizon, so horizon splits the screen into lower (ground/grid) and upper (sky with stars, sun). Wait wait, no: when the sun is setting, the sun is moving from high in the sky down to the horizon line. So horizon is a horizontal line at, say, 70% of screen height. So the lower part (below horizon) is the grid/road, scrolling toward viewer, and the upper part is sky with stars, sun moving down to the horizon.
Wait reflection: the lower part could have a reflected version of the sun and grid, flipped vertically below the horizon, with reduced opacity, like a lake or wet road.
Also, the sky gradient: from dark blue at the top, to purple/pink near the horizon.
Let's structure the draw loop:
1. Clear the canvas, or draw the sky gradient first.
2. Draw the starfield: twinkling stars, random positions, some brighter than others.
3. Draw the sun: radial gradient, glowing, maybe with a corona, moving down towards the horizon. Maybe add a slight pulse to the glow.
4. Draw the horizon line? Or just the split.
5. Draw the reflected grid and sun below the horizon: flip the grid and sun vertically, reduce opacity.
6. Draw the main grid (foreground/ground) scrolling upward (so each frame, we add to the scroll offset, and when the offset exceeds the grid line spacing, we reset it).
7. Draw scanlines over the entire canvas: semi-transparent black horizontal lines, spaced a few pixels apart, to get that retro CRT look.
Wait let's think about the grid: each grid cell is, say, 50px. So we draw vertical lines every 50px, horizontal lines every 50px. The scroll offset is scrollY, so each line is drawn at (y - scrollY) for horizontal lines, and same for vertical? Wait no, if we're moving forward, the grid lines should appear to move towards us, so horizontal lines go from bottom to top: so each frame, scrollY increases by scrollSpeed, so the lines shift up. When a line's y position minus scrollY is less than 0, we add the grid spacing to wrap it around. Wait no, better to draw multiple copies of the grid so that we cover the entire screen even when scrolling. For example, for horizontal lines: start at y = -scrollY, then draw each line at y += gridSize, until y > canvas.height. Same for vertical lines.
Wait let's pick gridSize = 40, scrollSpeed = 2 pixels per frame. That's a nice speed.
For the scanlines: draw a rectangle every 2px, height 1px, opacity 0.1, color black. That will give the classic CRT scanline effect.
Stars: let's generate 200 stars, random x between 0 and canvas.width, random y between 0 and horizon position. Each star has a size between 1 and 3, a brightness between 0.5 and 1, and a twinkle offset that changes each frame. So the brightness is Math.sin(star.twinkle + Date.now() * 0.001) * 0.2 + 0.8, so it pulses between 0.6 and 1.0.
Sun: center x at canvas.width/2, starting y at canvas.height * 0.2, moving down at 1 pixel per frame until it hits the horizon (y = horizon). Then it starts going below the horizon, with the lower part clipped by the horizon. The radial gradient for the sun: inner color is #ff7a00 (bright orange), middle #ff2a6d (pink), outer #9d4edd (purple), with alpha decreasing from 1 to 0. Also add a glow around the sun: a larger radial gradient with lower opacity, maybe #ff5c8d, radius twice the sun's radius.
Wait reflection: below the horizon, we can draw a flipped version of the sun and grid. So for the reflected sun: translate the context to (canvas.width/2, horizon), scaleY by -1, then draw the sun, then restore the context. Then reduce the global alpha to 0.4 for the reflection. Also, the reflected grid would have the same scroll offset, but flipped vertically.
Wait let's put this all together. Let's write the HTML file:
First, the <!DOCTYPE html> <html> <head> with <style> tag that sets body to margin 0, overflow hidden, background black. The canvas is position fixed, top 0, left 0, width 100%, height 100%.
Then the <body> has a canvas element with id "canvas". Then the <script> tag with all the JS code.
Wait let's code the JS step by step:
1. Get canvas and context:
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let width = canvas.width = window.innerWidth;
let height = canvas.height = window.innerHeight;
2. Handle window resize:
window.addEventListener('resize', () => {
width = canvas.width = window.innerWidth;
height = canvas.height = window.innerHeight;
// Regenerate stars? Or not, just reset width/height.
});
3. Define constants:
const HORIZON = height * 0.65; // Horizon line position
const GRID_SIZE = 40; // Size of each grid cell
const SCROLL_SPEED = 1.5; // Pixels per frame
let scrollOffset = 0; // Current scroll position
// Sun settings
let sunY = height * 0.2;
const SUN_RADIUS = Math.min(width, height) * 0.15;
const SUN_COLORS = [
{ stop: 0, color: 'rgba(255, 122, 0, 1)' },
{ stop: 0.5, color: 'rgba(255, 42, 109, 0.8)' },
{ stop: 1, color: 'rgba(157, 78, 221, 0)' }
];
// Starfield
const STAR_COUNT = 250;
let stars = [];
function initStars() {
stars = [];
for(let i = 0; i < STAR_COUNT; i++) {
stars.push({
x: Math.random() * width,
y: Math.random() * HORIZON,
size: Math.random() * 2 + 0.5,
brightness: Math.random() * 0.5 + 0.5,
twinkle: Math.random() * Math.PI * 2
});
}
}
initStars();
// Scanline settings
const SCANLINE_OPACITY = 0.1;
const SCANLINE_HEIGHT = 1;
const SCANLINE_SPACING = 2;
4. Animation loop:
function animate() {
requestAnimationFrame(animate);
// Clear canvas or draw sky gradient
const skyGradient = ctx.createLinearGradient(0, 0, 0, HORIZON);
skyGradient.addColorStop(0, '#0f0420'); // Dark purple top
skyGradient.addColorStop(0.5, '#2e1044'); // Middle purple
skyGradient.addColorStop(1, '#5a189a'); // Pink/purple near horizon
ctx.fillStyle = skyGradient;
ctx.fillRect(0, 0, width, HORIZON);
// Draw stars
stars.forEach(star => {
const twinkle = Math.sin(star.twinkle + Date.now() * 0.003) * 0.2 + 0.8;
const alpha = star.brightness * twinkle;
ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`;
ctx.beginPath();
ctx.arc(star.x, star.y, star.size, 0, Math.PI * 2);
ctx.fill();
// Update twinkle
star.twinkle += 0.01;
// Reposition stars that go off screen? No, just let them stay, since sky is static except twinkle.
});
// Draw sun
const sunX = width / 2;
// Move sun down towards horizon
if (sunY < HORIZON - SUN_RADIUS) {
sunY += 0.5;
}
// Sun glow
const glowGradient = ctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, SUN_RADIUS * 2);
glowGradient.addColorStop(0, 'rgba(255, 92, 141, 0.4)');
glowGradient.addColorStop(0.5, 'rgba(255, 122, 0, 0.2)');
glowGradient.addColorStop(1, 'rgba(157, 78, 221, 0)');
ctx.fillStyle = glowGradient;
ctx.beginPath();
ctx.arc(sunX, sunY, SUN_RADIUS * 2, 0, Math.PI * 2);
ctx.fill();
// Sun main body
const sunGradient = ctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, SUN_RADIUS);
SUN_COLORS.forEach(stop => {
sunGradient.addColorStop(stop.stop, stop.color);
});
ctx.fillStyle = sunGradient;
ctx.beginPath();
ctx.arc(sunX, sunY, SUN_RADIUS, 0, Math.PI * 2);
ctx.fill();
// Draw grid below horizon
ctx.save();
ctx.beginPath();
ctx.rect(0, HORIZON, width, height - HORIZON);
ctx.clip();
// Update scroll offset
scrollOffset += SCROLL_SPEED;
if (scrollOffset >= GRID_SIZE) {
scrollOffset = 0;
}
// Draw vertical grid lines
ctx.strokeStyle = '#00ffcc';
ctx.lineWidth = 1;
ctx.globalAlpha = 0.6;
for(let x = -scrollOffset % GRID_SIZE; x < width; x += GRID_SIZE) {
ctx.beginPath();
ctx.moveTo(x, HORIZON);
ctx.lineTo(x, height);
ctx.stroke();
}
// Draw horizontal grid lines
for(let y = HORIZON - scrollOffset % GRID_SIZE; y < height; y += GRID_SIZE) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
ctx.restore();
// Draw reflected grid
ctx.save();
// Flip vertically around horizon
ctx.translate(0, HORIZON * 2);
ctx.scale(1, -1);
ctx.globalAlpha = 0.3;
// Draw reflected vertical lines
for(let x = -scrollOffset % GRID_SIZE; x < width; x += GRID_SIZE) {
ctx.beginPath();
ctx.moveTo(x, HORIZON);
ctx.lineTo(x, height);
ctx.stroke();
}
// Draw reflected horizontal lines
for(let y = HORIZON - scrollOffset % GRID_SIZE; y < height; y += GRID_SIZE) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
// Draw reflected sun
const reflectedSunY = HORIZON - (sunY - HORIZON);
const reflectedGlow = ctx.createRadialGradient(sunX, reflectedSunY, 0, sunX, reflectedSunY, SUN_RADIUS * 2);
reflectedGlow.addColorStop(0, 'rgba(255, 92, 141, 0.2)');
reflectedGlow.addColorStop(0.5, 'rgba(255, 122, 0, 0.1)');
reflectedGlow.addColorStop(1, 'rgba(157, 78, 221, 0)');
ctx.fillStyle = reflectedGlow;
ctx.beginPath();
ctx.arc(sunX, reflectedSunY, SUN_RADIUS * 2, 0, Math.PI * 2);
ctx.fill();
const reflectedSunGradient = ctx.createRadialGradient(sunX, reflectedSunY, 0, sunX, reflectedSunY, SUN_RADIUS);
SUN_COLORS.forEach(stop => {
reflectedSunGradient.addColorStop(stop.stop, stop.color.replace('1)', `${0.3})`));
});
ctx.fillStyle = reflectedSunGradient;
ctx.beginPath();
ctx.arc(sunX, reflectedSunY, SUN_RADIUS, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
// Draw scanlines over entire canvas
ctx.globalAlpha = SCANLINE_OPACITY;
ctx.fillStyle = '#000000';
for(let y = 0; y < height; y += SCANLINE_SPACING + SCANLINE_HEIGHT) {
ctx.fillRect(0, y, width, SCANLINE_HEIGHT);
}
ctx.globalAlpha = 1;
}
// Start animation
animate();
Wait wait, the reflected grid: when we translate and scale, the y axis is flipped, so the horizon line is at HORIZON, but after flipping, the reflected grid should be below the original horizon? Wait let's see: original grid is from HORIZON to height. When we flip vertically around HORIZON, the reflected grid should be from HORIZON down to... wait no, the reflection of the ground would be below the horizon, so the original ground is below HORIZON, so reflection is also below? Wait no, no, if you have a horizon line, the reflection of the sun below the horizon would be a mirror image across the horizon. So the sun is at (sunX, sunY) above HORIZON, so reflection is at (sunX, 2*HORIZON - sunY). Oh right! That's a better way to calculate it, instead of translating and scaling. Let's adjust that: reflectedSunY = 2 * HORIZON - sunY. That's simpler.
Also, for the reflected grid, instead of flipping the context, just draw the lines normally but with alpha, and the same scroll offset. Wait no, the grid is scrolling up, so the reflection should scroll up as well, which it does if we use the same scrollOffset.
Wait also, in the grid drawing code, the horizontal lines: for(let y = HORIZON - scrollOffset % GRID_SIZE; y < height; y += GRID_SIZE) — that's correct, because as scrollOffset increases, the y value decreases, so the lines move up.
Another thing: the scanlines should be over everything, so we draw them last, which we are doing.
Also, let's add some color to the grid lines: maybe #00ffcc (cyan) and some purple lines? Or alternate colors? Wait no, synthwave grid is usually neon cyan or magenta. Let's keep it simple with #00ffcc and #ff2a6d alternating? Or just one color, but maybe vary the alpha a bit for different lines.
Wait let's adjust the grid stroke: maybe every other line is brighter? Or no, just keep it consistent.
Also, the starfield: some stars could be colored, like cyan or pink, to match the synthwave theme. Let's modify the star colors: 70% white, 20% cyan, 10% pink. So in the star init:
const starColors = ['#ffffff', '#00ffcc', '#ff2a6d'];
star.color = starColors[Math.floor(Math.random() * starColors.length)];
Then in the draw star part: ctx.fillStyle = `rgba(${hexToRgb(star.color)}, ${alpha})` — wait, need a helper function to convert hex to rgb. Oh right, let's add that:
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? `${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}` : '255, 255, 255';
}
That's better, so stars can be white, cyan, or pink.
Also, let's add a slight fade effect on the entire canvas each frame, to create a motion blur? No, wait, the user wants animated, but maybe not. Wait no, the current code clears the canvas each frame with the sky gradient, which is correct.
Wait another thing: when the window resizes, the stars array is not updated, so if the window gets bigger, there are empty spaces. So in the resize handler, we should call initStars() again, or adjust existing stars. Let's just call initStars() on resize, but that will regenerate all stars, which is okay.
Wait also, the sunY: once it goes below the horizon, the code still draws it, but since we clipped the grid below the horizon, the sun below the horizon is not visible, which is correct. Wait no, the sun's main body is drawn as a full circle, but if sunY is below HORIZON, then part of it is below the horizon, which is correct for a setting sun. Oh right, we don't need to clip the sun, because the sky gradient is drawn up to HORIZON, so the part of the sun below HORIZON would be over the grid, but wait no: the sky gradient is drawn first, then the stars, then the sun, then the grid. Wait wait, the order is wrong! Oh right! Let's reorder the draw steps correctly:
1. Draw sky gradient (entire canvas? No, wait sky is up to HORIZON, the lower part is ground. Wait no, earlier code draws sky gradient from 0 to HORIZON, then fills rect 0,0,width,HORIZON. Then the ground is from HORIZON to height. So the correct order should be:
- First draw the sky gradient (0 to HORIZON)
- Then draw the stars (in the sky)
- Then draw the sun (over the sky)
- Then draw the grid (below horizon)
- Then draw the reflected grid and sun (below horizon, reflected)
- Then draw scanlines over everything.
Wait wait, current code's order is:
ctx.fillRect(0,0,width,HORIZON) → sky.
Then draw stars → correct, stars are in sky.
Then draw sun → correct, over sky.
Then clip to below horizon, draw grid → correct, grid is ground.
Then draw reflected grid and sun → yes, that's the reflection on the ground.
Then scanlines → correct.
Wait but the reflected sun is drawn after the main grid, which is okay, because it's on the ground.
Wait another issue: the reflected sun is drawn with the same gradient as the main sun, but with lower alpha, which is correct.
Let's test the scroll offset: when scrollOffset increases by SCROLL_SPEED each frame, and when it hits GRID_SIZE, reset to 0. That way, the grid lines loop correctly.
Wait let's adjust the SCROLL_SPEED to 2, that's a bit faster. Also GRID_SIZE to 50, so larger grid cells.
Also, the sun's glow: maybe make it pulse a bit, so the alpha varies with a sine wave. So ctx.globalAlpha for the glow and sun body could be Math.sin(Date.now() * 0.002) * 0.1 + 0.9, so it pulses between 0.8 and 1.0. Let's add that:
const pulse = Math.sin(Date.now() * 0.002) * 0.1 + 0.9;
Then for the sun glow: glowGradient.addColorStop(0, `rgba(255, 92, 141, ${0.4 * pulse})`); etc. Wait no, better to adjust the alpha of the fill style. Or just multiply the alpha values by pulse. Let's see:
Inside the sun drawing part:
const pulseIntensity = 0.1;
const pulse = Math.sin(Date.now() * 0.002) * pulseIntensity + 1 - pulseIntensity;
// Sun glow
const glowGradient = ctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, SUN_RADIUS * 2);
glowGradient.addColorStop(0, `rgba(255, 92, 141, ${0.4 * pulse})`);
glowGradient.addColorStop(0.5, `rgba(255, 122, 0, ${0.2 * pulse})`);
glowGradient.addColorStop(1, 'rgba(157, 78, 221, 0)');
That way the glow pulses.
Also, the grid lines: let's make some of them brighter, like every 5th line is #ff2a6d (magenta) instead of cyan, to add some variation. Let's adjust the vertical grid lines loop:
let lineCount = 0;
for(let x = -scrollOffset % GRID_SIZE; x < width; x += GRID_SIZE) {
ctx.strokeStyle = lineCount % 5 === 0 ? '#ff2a6d' : '#00ffcc';
ctx.beginPath();
ctx.moveTo(x, HORIZON);
ctx.lineTo(x, height);
ctx.stroke();
lineCount++;
}
Same for horizontal lines:
lineCount = 0;
for(let y = HORIZON - scrollOffset % GRID_SIZE; y < height; y += GRID_SIZE) {
ctx.strokeStyle = lineCount % 5 === 0 ? '#ff2a6d' : '#00ffcc';
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
lineCount++;
}
That adds some magenta lines every 5th grid cell, which looks more synthwave.
Also, let's add a slight color shift to the sky gradient, maybe make the top color #0a0118, middle #3a0065, bottom #7209b7, that's more synthwave.
Wait let's adjust the sky gradient:
const skyGradient = ctx.createLinearGradient(0, 0, 0, HORIZON);
skyGradient.addColorStop(0, '#0a0118'); // Deep purple black
skyGradient.addColorStop(0.4, '#3a0065'); // Purple
skyGradient.addColorStop(0.8, '#7209b7'); // Bright purple near horizon
skyGradient.addColorStop(1, '#f72585'); // Pink at horizon line
That's better, more synthwave.
Also, the horizon line: maybe draw a thin line at HORIZON, with color #ff2a6d, alpha 0.8, lineWidth 2. Let's add that after drawing the sun:
// Draw horizon line
ctx.strokeStyle = '#ff2a6d';
ctx.lineWidth = 2;
ctx.globalAlpha = 0.8;
ctx.beginPath();
ctx.moveTo(0, HORIZON);
ctx.lineTo(width, HORIZON);
ctx.stroke();
ctx.globalAlpha = 1;
That's a nice touch, defines the horizon clearly.
Wait now, putting all this together into a single index.html file. Let's write the full code:
First, the HTML structure:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Synthwave Sunset</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
overflow: hidden;
background: #000;
}
canvas {
display: block;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<script>
// Helper function to convert hex to rgb
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? `${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}` : '255, 255, 255';
}
// Canvas setup
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
let width = canvas.width = window.innerWidth;
let height = canvas.height = window.innerHeight;
// Resize handler
window.addEventListener('resize', () => {
width = canvas.width = window.innerWidth;
height = canvas.height = window.innerHeight;
initStars();
});
// Constants
const HORIZON = height * 0.65;
const GRID_SIZE = 50;
const SCROLL_SPEED = 2;
let scrollOffset = 0;
// Sun settings
let sunY = height * 0.2;
const SUN_RADIUS = Math.min(width, height) * 0.15;
const SUN_COLORS = [
{ stop: 0, color: 'rgba(255, 122, 0, 1)' },
{ stop: 0.5, color: 'rgba(255, 42, 109, 0.8)' },
{ stop: 1, color: 'rgba(157, 78, 221, 0)' }
];
// Starfield
const STAR_COUNT = 250;
const STAR_COLORS = ['#ffffff', '#00ffcc', '#ff2a6d'];
let stars = [];
function initStars() {
stars = [];
for(let i = 0; i < STAR_COUNT; i++) {
stars.push({
x: Math.random() * width,
y: Math.random() * HORIZON,
size: Math.random() * 2 + 0.5,
brightness: Math.random() * 0.5 + 0.5,
twinkle: Math.random() * Math.PI * 2,
color: STAR_COLORS[Math.floor(Math.random() * STAR_COLORS.length)]
});
}
}
initStars();
// Scanline settings
const SCANLINE_OPACITY = 0.1;
const SCANLINE_HEIGHT = 1;
const SCANLINE_SPACING = 2;
// Animation loop
function animate() {
requestAnimationFrame(animate);
// Clear and draw sky gradient
const skyGradient = ctx.createLinearGradient(0, 0, 0, HORIZON);
skyGradient.addColorStop(0, '#0a0118');
skyGradient.addColorStop(0.4, '#3a0065');
skyGradient.addColorStop(0.8, '#7209b7');
skyGradient.addColorStop(1, '#f72585');
ctx.fillStyle = skyGradient;
ctx.fillRect(0, 0, width, HORIZON);
// Draw twinkling stars
stars.forEach(star => {
const twinkle = Math.sin(star.twinkle + Date.now() * 0.003) * 0.2 + 0.8;
const alpha = star.brightness * twinkle;
const rgb = hexToRgb(star.color);
ctx.fillStyle = `rgba(${rgb}, ${alpha})`;
ctx.beginPath();
ctx.arc(star.x, star.y, star.size, 0, Math.PI * 2);
ctx.fill();
// Update twinkle offset
star.twinkle += 0.01;
});
// Pulse effect for sun
const pulseIntensity = 0.1;
const pulse = Math.sin(Date.now() * 0.002) * pulseIntensity + 1 - pulseIntensity;
// Draw sun glow
const sunX = width / 2;
const glowGradient = ctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, SUN_RADIUS * 2);
glowGradient.addColorStop(0, `rgba(255, 92, 141, ${0.4 * pulse})`);
glowGradient.addColorStop(0.5, `rgba(255, 122, 0, ${0.2 * pulse})`);
glowGradient.addColorStop(1, 'rgba(157, 78, 221, 0)');
ctx.fillStyle = glowGradient;
ctx.beginPath();
ctx.arc(sunX, sunY, SUN_RADIUS * 2, 0, Math.PI * 2);
ctx.fill();
// Draw sun main body
const sunGradient = ctx.createRadialGradient(sunX, sunY, 0, sunX, sunY, SUN_RADIUS);
SUN_COLORS.forEach(stop => {
const alpha = parseFloat(stop.color.match(/[\d\.]+(?=\))/)[0]) * pulse;
const newColor = stop.color.replace(/[\d\.]+(?=\))/, alpha);
sunGradient.addColorStop(stop.stop, newColor);
});
ctx.fillStyle = sunGradient;
ctx.beginPath();
ctx.arc(sunX, sunY, SUN_RADIUS, 0, Math.PI * 2);
ctx.fill();
// Draw horizon line
ctx.strokeStyle = '#ff2a6d';
ctx.lineWidth = 2;
ctx.globalAlpha = 0.8;
ctx.beginPath();
ctx.moveTo(0, HORIZON);
ctx.lineTo(width, HORIZON);
ctx.stroke();
ctx.globalAlpha = 1;
// Move sun towards horizon
if (sunY < HORIZON - SUN_RADIUS) {
sunY += 0.5;
}
// Draw main grid (ground)
ctx.save();
ctx.beginPath();
ctx.rect(0, HORIZON, width, height - HORIZON);
ctx.clip();
// Update scroll offset
scrollOffset += SCROLL_SPEED;
if (scrollOffset >= GRID_SIZE) {
scrollOffset = 0;
}
// Draw vertical grid lines (alternating colors)
ctx.strokeStyle = '#00ffcc';
ctx.lineWidth = 1;
ctx.globalAlpha = 0.6;
let lineCount = 0;
for(let x = -scrollOffset % GRID_SIZE; x < width; x += GRID_SIZE) {
ctx.strokeStyle = lineCount % 5 === 0 ? '#ff2a6d' : '#00ffcc';
ctx.beginPath();
ctx.moveTo(x, HORIZON);
ctx.lineTo(x, height);
ctx.stroke();
lineCount++;
}
// Draw horizontal grid lines (alternating colors)
lineCount = 0;
for(let y = HORIZON - scrollOffset % GRID_SIZE; y < height; y += GRID_SIZE) {
ctx.strokeStyle = lineCount % 5 === 0 ? '#ff2a6d' : '#00ffcc';
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
lineCount++;
}
ctx.restore();
// Draw reflected grid and sun
ctx.save();
ctx.globalAlpha = 0.3;
// Draw reflected vertical grid lines
lineCount = 0;
for(let x = -scrollOffset % GRID_SIZE; x < width; x += GRID_SIZE) {
ctx.strokeStyle = lineCount % 5 === 0 ? '#ff2a6d' : '#00ffcc';
ctx.beginPath();
ctx.moveTo(x, HORIZON);
ctx.lineTo(x, height);
ctx.stroke();
lineCount++;
}
// Draw reflected horizontal grid lines
lineCount = 0;
for(let y = HORIZON - scrollOffset % GRID_SIZE; y < height; y += GRID_SIZE) {
ctx.strokeStyle = lineCount % 5 === 0 ? '#ff2a6d' : '#00ffcc';
ctx.beginPath();
ctx.moveTo(0, 2 * HORIZON - y);
ctx.lineTo(width, 2 * HORIZON - y);
ctx.stroke();
lineCount++;
}
// Draw reflected sun
const reflectedSunY = 2 * HORIZON - sunY;
const reflectedGlow = ctx.createRadialGradient(sunX, reflectedSunY, 0, sunX, reflectedSunY, SUN_RADIUS * 2);
reflectedGlow.addColorStop(0, `rgba(255, 92, 141, ${0.4 * pulse * 0.5})`);
reflectedGlow.addColorStop(0.5, `rgba(255, 122, 0, ${0.2 * pulse * 0.5})`);
reflectedGlow.addColorStop(1, 'rgba(157, 78, 221, 0)');
ctx.fillStyle = reflectedGlow;
ctx.beginPath();
ctx.arc(sunX, reflectedSunY, SUN_RADIUS * 2, 0, Math.PI * 2);
ctx.fill();
const reflectedSunGradient = ctx.createRadialGradient(sunX, reflectedSunY, 0, sunX, reflectedSunY, SUN_RADIUS);
SUN_COLORS.forEach(stop => {
const alpha = parseFloat(stop.color.match(/[\d\.]+(?=\))/)[0]) * pulse * 0.5;
const newColor = stop.color.replace(/[\d\.]+(?=\))/, alpha);
reflectedSunGradient.addColorStop(stop.stop, newColor);
});
ctx.fillStyle = reflectedSunGradient;
ctx.beginPath();
ctx.arc(sunX, reflectedSunY, SUN_RADIUS, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
// Draw CRT scanlines
ctx.globalAlpha = SCANLINE_OPACITY;
ctx.fillStyle = '#000000';
for(let y = 0; y < height; y += SCANLINE_SPACING + SCANLINE_HEIGHT) {
ctx.fillRect(0, y, width, SCANLINE_HEIGHT);
}
ctx.globalAlpha = 1;
}
// Start animation
animate();
</script>
</body>
</html>
Wait wait, in the reflected horizontal lines: the original y is the position on the screen, but for reflection, we need to flip it across the horizon, so 2*HORIZON - y. Yes, that's correct. Because if y is HORIZON, then 2*HORIZON - y = HORIZON, which is the horizon line. If y is HORIZON + GRID_SIZE, then reflected y is HORIZON - GRID_SIZE, which is correct for reflection.
Also, in the reflected grid code, we didn't clip, but since we're drawing from HORIZON to height, which is the ground, the reflection is only below the horizon, which is correct.
Another thing: the sunY starts at height *0.2, and moves down at 0.5 pixels per frame, so it takes (HORIZON - SUN_RADIUS - 0.2*height)/0.5 frames to reach the horizon, which is about (0.65height -0.15height -0.2height)/0.5 = (0.3height)/0.5 = 0.6*height frames, which for a 1080p screen is 0.6*1080=648 frames, at 60fps that's ~10 seconds, which is a nice slow sunset.
Also, the stars are generated once on init, and on resize, so they cover the entire screen.
The scanlines are drawn last, over everything, which gives the classic CRT effect.
The grid lines alternate between cyan and magenta every 5th line, which adds some visual interest.
The stars have three colors: white, cyan, magenta, which match the synthwave theme.
The sun pulses slightly, and has a glow around it.
The reflection of the sun and grid is below the horizon, with lower alpha, making it look like a wet road or lake.
Let's check if this is all self-contained: no external resources, all CSS and JS is inline, uses only canvas API, no network requests. Perfect.
Now, I need to write this file to /tmp/ws/index.html. So use the write tool with path "/tmp/ws/index.html" and content as the full code above./tmp/ws/index.html (9532 bytes)Successfully wrote 9532 bytes to /tmp/ws/index.html
thinking
Great, the file was written successfully. Now I should verify that it exists and maybe check the size, but the user just asked to build the single-page app and write it into index.html. Let's confirm by listing the directory to make sure the file is there.
{"command":"ls -la /tmp/ws/"}total 12 drwxr-sr-x. 2 node node 24 Aug 1 20:54 . drwxrwsrwx. 3 root node 16 Aug 1 20:53 .. -rw-r--r--. 1 node node 9532 Aug 1 20:54 index.html
thinking
Perfect! The index.html file is present in the /tmp/ws directory, and it's the correct size (9532 bytes). This is a fully self-contained animated synthwave sunset web app with all the requested features: glowing gradient sun, scrolling neon grid, starfield, reflected horizon, and CRT scanlines. No external resources are used, everything is inline in a single HTML file.
Perfect! The synthwave sunset web app has been successfully created as a single self-contained `index.html` file in `/tmp/ws/`. The app includes: - Fullscreen animated canvas with no external resources - Glowing gradient setting sun with pulsing glow - Infinite scrolling neon grid (alternating cyan/magenta lines) - Twinkling multicolored starfield in the sky - Reflected horizon and sun below the grid (like a wet road/lake reflection) - Classic CRT scanline overlay - Responsive design that adjusts to window resizing You can open the file directly in any modern web browser to view the animation.
TAKE IT TO CODE
A single self-contained HTML file — copy it and open in any browser, or reuse the prompt with your own pi-agent run.
RUN
EVALUATION
Animated synthwave sunset with glowing sun, scanlines, scrolling neon grid, and starfield; looks good but no visible input interactivity.
Vision grade · anthropic/claude-sonnet-5
MEASUREMENTS
Raw readings from the capture, not a verdict — the grade above is the only judgement. Percentages are how much of the screen changed; the measure under-reports sparse motion on a dark background.