Introduction
This scene reads as sky, sun, sea, wet sand, dry sand, and a palm tree, and every one of those is a plain absolutely-positioned div — no SVG, no canvas, no clip-path curve syntax. The sea in particular is a trick worth knowing on its own: it looks like a wave because it is an oval twice as wide as the screen, rounded only on its bottom corners, sitting with its top half hidden above the viewport and its flat top edge doing the work of looking like a horizon.
The technique rests on four ideas: a single radial-gradient circle stands in for the sun, glowing by animating its own scale and opacity rather than any light source — the sea is a border-radius oval, not a curve, stretched to 200% width so its rounded corners fall off both edges of the screen and only the flat top edge is ever visible — a second, independent div layered on top of the sea fades a white gradient in and out on its own timer to fake foam without ever touching the sea's own animation — and every layer that repeats the same trick (sea, foam, wet sand) gets its own class and its own keyframes, deliberately not shared, so that retiming one never risks retiming another.
How an oversized oval standing mostly off-screen can read as a wave without any curve math — how to make a wave "swell" with nothing but scaleY and a transform-origin pinned to its top edge — how layering a second, independently-timed div creates foam without coupling its animation to the wave underneath it — how a few nested border tricks build a recognizable palm tree with zero images — and a real copy-paste bug worth knowing about, where a stray leftover keyframe fragment produced a strange static white patch that had nothing to do with color at all.
Step 1 — One Gradient, One Glowing Circle
The sky is nothing more than a linear-gradient div pinned to the top 40% of the viewport. The sun sits on top of it as a separate, small, absolutely-positioned circle — a radial-gradient that goes bright at the center and fully transparent at the edge, so it blends into the sky instead of showing a hard-edged disc. It has no light-casting effect on anything else in the scene; its only job is to breathe gently in scale and opacity so the sky doesn't feel static.
.sun {
top: 10%;
left: 15%;
width: 8rem;
height: 8rem;
border-radius: 50%;
background: radial-gradient(
circle,
#fff8dd 0%,
#ffe89a 45%,
rgba(255, 232, 154, 0.15) 75%,
rgba(255, 232, 154, 0) 100%
);
animation: sun-glow 6s ease-in-out infinite;
}
@keyframes sun-glow {
0%, 100% { transform: scale(1); opacity: 0.9; }
50% { transform: scale(1.06); opacity: 1; }
}Step 2 — border-radius Instead of a Curve
.sea is 200% wide and offset left by -50%, so it always overhangs both edges of the viewport — there is never a visible left or right edge to give away that it is a rectangle underneath. border-radius: 0 0 50% 50% rounds only the bottom-left and bottom-right corners into a wide, shallow arc; because the element is so much wider than it is tall, that arc reads as a gentle horizon line rather than an obvious oval.
.sea {
height: 30%;
width: 200%;
left: -50%;
top: 40%;
border-radius: 0 0 50% 50%;
background: linear-gradient(
to bottom,
rgba(8, 122, 193, 1) 0%,
rgba(18, 156, 192, 1) 25%,
rgba(42, 212, 229, 1) 50%,
rgba(150, 233, 239, 1) 75%,
rgba(253, 241, 215, 1) 100%
);
animation: wave ease-in-out 10s infinite;
transform-origin: 50% 0;
overflow: hidden;
}
@keyframes wave {
0% { transform: scaleY(1); }
35% { transform: scaleY(1.3); }
69% { transform: scaleY(1); }
100% { transform: scaleY(1); }
}The wave never redraws its own outline. scaleY with transform-origin pinned to the top edge is what makes the swell look like it rises from the horizon and settles back — the shape itself never changes, only how far it stretches.
That last gradient stop matters more than it looks like it should: it has to match the color of whatever the sea overlaps at rest, which here is the sand. Get that one stop wrong and the wave leaves a visible seam exactly where it meets the beach, even though the mismatch has nothing to do with the wave's shape or motion — a good reminder that seams like that are usually a color problem, not a geometry problem.
Step 3 — A Second Div, A Separate Animation
.seafoam lives inside .sea at 100% width and height, so it inherits the same clipped oval shape for free. Its own background is a gradient that is fully transparent until the very bottom, then turns solid white — and instead of moving that gradient, the div's opacity is what animates, fading the white edge in and back out. Because this is a separate animation with its own duration and delay, the foam can be retimed, sped up, or removed entirely without ever touching the wave's scaleY keyframes.
.seafoam {
height: 100%;
width: 100%;
background: linear-gradient(to bottom, transparent 0%, transparent 90%, white 100%);
animation: seafoam ease-in-out 10s infinite;
}
@keyframes seafoam {
0% { opacity: 0; }
30% { opacity: 1; }
50% { opacity: 0; }
100% { opacity: 0; }
}A second and third wave (.sea-2, .sea-3 in earlier drafts of this scene) reuse the exact same oval-plus-foam pattern at different heights, opacities, and negative animation-delays, so several waves appear to roll in in a staggered set instead of one wave breathing in lockstep. Each extra layer is a full copy of the pattern with its own class names — nothing is shared or parameterized — which keeps any one wave's timing changes from silently affecting another.
Step 4 — The Same Oval Trick, Used for Color Instead of Shape
.wet-sand is built exactly like .sea — an oversized oval with rounded bottom corners, positioned to sit just behind the dry sand — but it never moves. Instead, its opacity animates on the same 10-second rhythm as the wave's swell, so the sand underneath the sea darkens right as the wave stretches furthest down the beach, and lightens back as the wave recedes. It is a color cue standing in for a physical effect: nothing about the sand geometry actually changes.
.wet-sand {
height: 37.5%;
width: 200%;
left: -50%;
top: 40%;
border-radius: 0 0 50% 50%;
background: #ecc075;
box-shadow: 0 10px 10px 0 #ecc075;
animation: wetsand ease-in-out 10s infinite;
}
@keyframes wetsand {
0% { opacity: 0.2; }
34% { opacity: 0.2; }
35% { opacity: 0.7; }
100% { opacity: 0.2; }
}Step 5 — border-radius and border-color, Nothing Else
The trunk is a circle (border-radius: 50%) with a transparent bottom border and a solid, colored right border — the classic CSS triangle trick, but on a rounded shape instead of a square one, which is what gives it a slight curve instead of a straight diagonal edge. Each palm leaf is the same idea rotated to a different angle: a circle with only its top border given a thick, solid color, so that border arcs into a leaf-like frond. Layering three of these at different rotations and shades of green is enough to read as foliage without a single image asset.
.trunk {
height: 200%;
width: 70%;
left: 25%;
top: -10%;
border-radius: 50%;
border-bottom: 100px solid transparent;
border-right: 100px solid #aa8366;
}
.leaves-1 {
height: 70%;
width: 50%;
left: 50%;
top: -25%;
transform: rotate(-20deg);
border-radius: 50%;
border-top: 200px solid #394d00;
}While trimming a third wave layer out of this scene, a mid-edit copy-paste left a duplicated tail end of one keyframe block and an unmatched closing brace sitting in the stylesheet. Browsers don't always fail loudly on malformed CSS — instead of an error, the result was a static white patch that looked like a color or z-index problem but was actually just broken syntax silently discarding part of the rule. The fix was checking that every opening brace in the stylesheet had a matching close; the lesson is that a visual glitch with no obvious cause is worth a brace count before it's worth a color audit.
Tuning Reference
| Property | Example | Effect |
|---|---|---|
| .sea top / height | 40% / 30% | Where the horizon sits and how tall the visible water band is |
| wave scaleY peak | 1.3 | How far the swell stretches down over the sand at its highest point |
| wave keyframe % | 0 → 35 → 69 → 100 | Uneven timing keeps the rise feeling quicker than the settle |
| seafoam gradient stop | transparent 90%, white 100% | How thin the visible foam edge is relative to the whole wave |
| wetsand opacity range | 0.2 – 0.7 | How dramatic the tide-mark darkening reads against the dry sand |
| leaf border-top width | 200px | Thickness of each frond; larger values read as broader palm leaves |
Full Source Code
The complete effect is a single self-contained HTML document — sky, sun, layered sea/foam/wet-sand divs, a palm tree built from borders, and a dry-sand strip in front. No JavaScript, no build step, no external assets.
// Full source available in the Aduok GitHub repository
// https://github.com/aduok/aduok-code-snippets/blob/main/blog/beach-scene.htmlOne oversized oval standing in for a wave, one independent div fading foam on top of it, and a handful of rotated border tricks for the palm tree. The whole scene is well under 200 lines of CSS, and not a single curve is drawn by hand.