👻 Haunted Loop: A Pure-CSS Halloween Scene
This is a submission for Frontend Challenge - Halloween Edition, CSS Art.
Inspiration
I wanted a looping “mini-horror short” built only with CSS: a crescent moon drifts, a witch flies past, a ghost peeks from a window, bats swarm, and a neon “ENTER” sign flickers like a haunted arcade. The whole thing is a nod to old-school 8-bit spooky intros — minimal shapes, big vibes.
Demo
Paste this into CodePen (HTML panel) or save as a single .html file and open in your browser. It’s pure CSS (no images, no JavaScript).
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Haunted Loop — Pure CSS Halloween</title>
<style>
:root{
--bg:#0a0b10;
--sky1:#0b0f1c;
--sky2:#121a30;
--moon:#ffd68a;
--house:#0e0f14;
--window:#f0f6ff;
--ghost:#f8fbff;
--accent:#7c9bff;
--neon:#ff5a8a;
--fog:#a8b1ff1a;
}
*{box-sizing:border-box}
html,body{height:100%}
body{
margin:0; background: radial-gradient(120% 120% at 50% 10%, var(--sky2), var(--bg) 70%);
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial;
color:#cfd6ff; display:grid; place-items:center;
}
.scene{
width:min(92vw, 840px); aspect-ratio:16/9; position:relative;
border-radius:18px; overflow:hidden; background:
radial-gradient(120% 140% at 50% -10%, var(--sky1), transparent 60%),
linear-gradient(var(--bg), var(--bg));
box-shadow: 0 20px 60px #0008, inset 0 0 0 1px #ffffff0f;
isolation:isolate;
}
/* Moon */
.moon{
position:absolute; top:6%; left:10%;
width:86px; height:86px; border-radius:50%;
background:radial-gradient(65% 65% at 35% 35%, #fff4cc, var(--moon) 55%, #eabf6a 66%, #0000 67%);
box-shadow:0 0 24px 6px #ffd68a55, 0 0 120px 18px #ffd68a22;
animation: drift 18s linear infinite;
}
.moon::after{
content:""; position:absolute; inset:0;
background: radial-gradient(40% 40% at 65% 45%, #0000 45%, #000 46% 100%);
border-radius:50%; transform: translateX(8px);
mix-blend-mode:multiply; opacity:.25;
}
@keyframes drift{ 0%{transform:translateX(0)} 100%{transform:translateX(22vw)} }
/* Stars */
.stars, .stars::before, .stars::after{
position:absolute; inset:0; content:""; background:
radial-gradient(2px 2px at 10% 20%, #fff8 98%, #0000) 0 0/15% 15%,
radial-gradient(1.5px 1.5px at 40% 60%, #fff6 98%, #0000) 0 0/22% 22%,
radial-gradient(1.5px 1.5px at 85% 30%, #fff7 98%, #0000) 0 0/18% 18%;
opacity:.5; filter:saturate(130%);
}
.stars{opacity:.35}
.stars::before{opacity:.4; transform:translateY(2px)}
.stars::after{opacity:.25; transform:translateY(-2px)}
/* Ground silhouette */
.ground{
position:absolute; left:-5%; right:-5%; bottom:-2%;
height:38%; background:
radial-gradient(120% 120% at 50% -10%, #0000 65%, #000 66%),
linear-gradient(#0b0c12, #07080e);
clip-path: polygon(0 40%, 15% 45%, 22% 43%, 30% 48%, 40% 46%, 48% 50%, 62% 42%, 73% 47%, 83% 45%, 100% 40%, 100% 100%, 0 100%);
filter: drop-shadow(0 -8px 16px #000c);
}
/* House */
.house{
position:absolute; bottom:14%; left:50%; transform:translateX(-50%);
width:min(50%, 420px); height:48%;
}
.house .body{
position:absolute; bottom:0; left:8%; right:8%; height:68%;
background: linear-gradient(#0d0f17, #090b12);
box-shadow: inset 0 0 0 1px #ffffff08;
}
.roof{
position:absolute; bottom:68%; left:4%; right:4%; height:22%;
background: linear-gradient(#0b0d15, #070910);
clip-path: polygon(0 100%, 50% 0, 100% 100%);
filter: drop-shadow(0 6px 0 #05060b);
}
.chimney{
position:absolute; bottom:78%; left:12%; width:6%; height:18%;
background:#0b0d14; box-shadow: inset 0 0 0 1px #ffffff10;
}
/* Windows */
.window{
--glow:#a6b9ff;
position:absolute; width:17%; height:22%;
background: radial-gradient(120% 120% at 20% 20%, #fff, var(--window) 55%, #9ab0ff 85%);
box-shadow:
0 0 12px 4px #a6b9ff33,
inset 0 0 0 2px #ffffff0f;
outline: 1px solid #ffffff12;
border-radius:2px; overflow:hidden;
}
.window.left{ left:16%; bottom:24% }
.window.right{ right:16%; bottom:24% }
.window.center{ left:50%; transform:translateX(-50%); bottom:48%; height:24% }
/* Ghost peeking in left window */
.window.left .ghost{
position:absolute; bottom:-6%; left:50%; transform:translateX(-50%);
width:64%; height:90%; border-radius:24px 24px 14px 14px;
background: radial-gradient(80% 100% at 50% 0%, var(--ghost), #e7ecff 65%, #0000 66%);
animation: peek 6s ease-in-out infinite;
}
.ghost::before, .ghost::after{
content:""; position:absolute; top:34%; width:11px; height:11px; border-radius:50%;
background:#0d1b2a; box-shadow: 0 0 0 2px #ffffff55 inset;
}
.ghost::before{ left:30% }
.ghost::after{ right:30% }
@keyframes peek{
0%, 100% { transform: translate(-50%, 0) }
40% { transform: translate(-50%, -14%) }
60% { transform: translate(-50%, -8%) }
}
/* Neon sign */
.sign{
position:absolute; right:8%; bottom:10%;
width:120px; height:48px; border-radius:6px;
background: #2a0b18; border:2px solid #3a1422;
display:grid; place-items:center; letter-spacing:1px;
color:#fff; font-weight:800; font-size:14px;
text-shadow:0 0 10px var(--neon), 0 0 22px var(--neon);
box-shadow: inset 0 0 12px 2px #000, 0 0 34px 6px #ff5a8a2f;
animation: flicker 3.2s steps(12) infinite;
}
@keyframes flicker{
0%, 4%, 8%, 12%, 100% { filter:brightness(1) saturate(1.2) }
2%, 6%, 10% { filter:brightness(.3) saturate(.6) }
}
/* Bats group */
.bat{
position:absolute; top:14%; left:-10%;
width:28px; height:12px; background:#0b0d14; border-radius:8px;
box-shadow:
-16px 0 0 0 #0b0d14,
16px 0 0 0 #0b0d14;
transform-origin:50% 50%;
animation: fly 10s linear infinite;
opacity:.85;
}
.bat::before, .bat::after{
content:""; position:absolute; top:-8px; width:18px; height:18px; border:2px solid #0b0d14; border-bottom-color:transparent; border-right-color:transparent;
border-radius: 0 0 0 100%;
}
.bat::before{ left:-18px; transform:rotate(-30deg) }
.bat::after{ right:-18px; transform: scaleX(-1) rotate(-30deg) }
.bat.b2{ top:22%; animation-duration: 12s; transform:scale(.9); opacity:.7 }
.bat.b3{ top:18%; animation-duration: 14s; transform:scale(1.1); opacity:.6 }
@keyframes fly{
0% { transform: translateX(0) translateY(0) rotate(0deg) }
25% { transform: translateX(25vw) translateY(-8px) rotate(3deg) }
50% { transform: translateX(50vw) translateY(4px) rotate(-2deg) }
75% { transform: translateX(75vw) translateY(-10px) rotate(2deg) }
100% { transform: translateX(105vw) translateY(0) rotate(0deg) }
}
/* Witch silhouette sweeping across the moon */
.witch{
position:absolute; top:10%; left:-12%; width:80px; height:18px;
background:#0b0d14; border-radius:3px;
box-shadow: 10px 6px 0 6px #0b0d14; /* body + skirt block */
transform-origin:50% 50%;
animation: sweep 12s ease-in-out infinite;
}
.witch::before{
content:""; position:absolute; left:8px; top:-10px; width:20px; height:20px; border-radius:50%; background:#0b0d14; /* head */
box-shadow: 14px 6px 0 2px #0b0d14; /* hat brim */
}
.witch::after{
content:""; position:absolute; left:-22px; top:8px; width:0; height:0; border-left:26px solid #0b0d14; border-top:6px solid transparent; border-bottom:6px solid transparent; /* broom */
}
@keyframes sweep{
0%{ transform: translateX(0) translateY(0) rotate(-2deg) }
45%{ transform: translateX(48vw) translateY(-6px) rotate(0deg) }
50%{ transform: translateX(52vw) translateY(-4px) rotate(1deg) }
100%{ transform: translateX(108vw) translateY(0) rotate(-1deg) }
}
/* Fog layers */
.fog,.fog::before,.fog::after{
position:absolute; left:-10%; right:-10%; bottom:10%;
height:22%; content:""; background:
radial-gradient(60% 100% at 20% 80%, var(--fog), transparent 70%),
radial-gradient(60% 100% at 70% 80%, var(--fog), transparent 70%);
filter: blur(6px);
animation: fog 18s linear infinite;
}
.fog::before{ animation-duration: 22s; transform:translateY(8px) scaleY(1.15) }
.fog::after { animation-duration: 26s; transform:translateY(-8px) scaleY(.9) }
@keyframes fog{
0%{ transform: translateX(0) }
100%{ transform: translateX(-12%) }
}
/* Accessibility */
.sr-only{ position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
</style>
</head>
<body>
<div class="scene" role="img" aria-label="A haunted house at night with a crescent moon, bats, a flying witch, flickering neon sign, drifting fog, and a shy ghost in the window.">
<div class="stars"></div>
<div class="moon" aria-hidden="true"></div>
<div class="witch" aria-hidden="true"></div>
<div class="bat b1" aria-hidden="true"></div>
<div class="bat b2" aria-hidden="true"></div>
<div class="bat b3" aria-hidden="true"></div>
<div class="house" aria-hidden="true">
<div class="roof"></div>
<div class="chimney"></div>
<div class="body"></div>
<div class="window left"><div class="ghost"></div></div>
<div class="window center"></div>
<div class="window right"></div>
</div>
<div class="sign">ENTER</div>
<div class="ground"></div>
<div class="fog" aria-hidden="true"></div>
<span class="sr-only">Looping animation; no flashing above WCAG thresholds.</span>
</div>
</body>
</html>
Embed on DEV:
If you prefer CodePen embed, create a Pen with that HTML and add to the post as:
{% codepen https://codepen.io/<your-user>/pen/<id> %}
Journey
- Constraints as style: I limited myself to shapes via gradients, borders, and box-shadows (no images, no SVGs).
- Motion grammar: Each element animates with a distinct “character” — the witch sweeps (ease-in-out), bats flutter (linear with slight y-jitter), the neon sign flickers (stepped).
-
Accessibility: The scene has a descriptive
aria-label, a screen-reader note, and flicker stays under common seizure thresholds (short bursts, low duty cycle). - Performance: No JS, no external assets; it’s all GPU-friendly transforms/opacity. It runs smoothly on mobile.
What I’d do next
- Add a “parallax scroll” variant with CSS
perspective. - Create a color-scheme switcher (pumpkin / toxic green / midnight cyan) using
@media (prefers-color-scheme)and custom properties. - Build a “scene editor” UI that lets people tweak moon size, sign text, fog density — then exports a static CSS art snapshot.
License
MIT. Credit is appreciated but not required — remix away.
Team
Solo submission — Peace Thabiwa (Botswana) — concept builder, BINFLOW/Web4 enthusiast.
Top comments (0)