Glowing edges with CSS and WebGL shaders
21 Sept 2026•13 min read
Contents
I initially built this in 2024, when apple rolled out the new siri apple intelligence redesign but then with vanilla CSS only.
Recently I have been learning about shaders, and this effect felt like the right thing to come back to and tinker around. So I built this again but leveraging WebGL and shaders this time.
This article breaks down both the approaches in detail. Try them first
With vanilla CSS
The thought process was to build this from nothing, one property at a time. It starts as a single element, and I'll only add more when we run into a problem that actually needs them.
The overlay
The glow sits on top of the whole page, so the first thing we need is a sheet to paint it on: one element that covers the screen and floats above everything else.
import styles from "./style.module.css";
export function EdgeGlow() {
return <div className={styles.glow} />;
}That's the whole component for now. It renders one empty div, and the stylesheet does the rest.
.glow {
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
}Each line has a specific job:
- position: fixed - pins the element to the screen rather than the page. Think of it like a frame on your monitor, the page underneath can scroll, but the frame stays put. The glow hugs the edges of the screen, so this is what we want.
- inset: 0 - stretches it until it touches all four edges. It's shorthand for setting
top,right,bottomandleftall to0, so the sheet ends up exactly the size of the screen. - z-index: 9999 - puts it on top of the stack of elements, above anything else that's already floating, I have way too many elements on my site so this stands top of the stack.
- pointer-events: none - turns the sheet into a ghost.
That last one matters more than it looks. The sheet covers every pixel of the page, so without it, it would catch every click and nothing underneath would work. With it, the sheet still paints, but the mouse passes straight through.
The playground uses plain HTML with the same class names, so you can edit it without a build step. The red border is only there to show where the sheet is. Try selecting the text in the center first, then delete pointer-events: none and try again. You'll get what I'm trying to say!
Painting the rainbow
Now we give the sheet some colour. We'll use six colours. We paint them onto .glow as its background. I wanted to closely depict siri animation and make it vibrant, thats why the choices. You can try differently.
.glow {
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
background: conic-gradient(
#bc82f3,
#f5b9ea,
#8d9fff,
#ff6778,
#ffba71,
#c686ff,
#bc82f3
);
}The component doesn't change. It's still one empty div.
We are using conic-gradient. There's a center, and colours are painted around it like the hand of a clock broken into different slices, starting at 12 o'clock, going clockwise, with each colour getting its own slice. The browser blends each slice smoothly into the next, so there are no hard lines between them.
Every colour points outward from the middle. That's the property we'll lean on in the next step. Once we cut the middle away, each edge of the screen catches a different slice: purple along the top, blue down the right, red and orange along the bottom.
You might have noticed the first colour appears twice. The gradients goes all the way around and arrives back at 12 o'clock, right where it started. If the last colour were different from the first, you'd see a hard line there, where the final slice meets the first one. Ending on the same purple we began with closes the circle cleanly.
Right now the rainbow conic gradient covers the entire page. That's expected the sheet is painted solid, so it hides everything underneath. We'll cut the middle out next.
In the playground, replace the last #bc82f3 with #ff6778 and look straight up from the centre. That's the seam.
Masking the edges
The rainbow should only show near the edges, so we need to hide the middle. CSS has a tool for exactly this - a mask.
A mask works like a stencil, a sheet with holes cut in it. Where there's a hole, you see what's underneath. Where there isn't, it's hidden. In CSS, the mask is an image, and its colours decide what shows:
- black means show this part
- transparent means hide this part
- anything in between means show it partly faded
The trick is to build the stencil from gradients, so the holes have soft, fading edges instead of hard ones.
Let's start with just the left edge:
.glow {
/* ...everything from before... */
mask-image: linear-gradient(to right, black, transparent);
mask-size: 8vmin 100%;
mask-position: left;
mask-repeat: no-repeat;
}mask-imageis the stencil itself. Starting at the left, it fades from solid black to see-through.mask-sizemakes it8vminwide and the full height of the screen.vminis a percentage of whichever is smaller, the screen's width or its height, so8vminis 8% of the short side. The glow keeps the same proportions on a phone and on a wide monitor.mask-positionputs the stencil against the left wall.mask-repeatstops the browser tiling the stencil across the whole screen, which is what it would do by default.
Now only a strip down the left side keeps its colour, fading as it moves right. For all four edges, we stack four stencils. CSS lets you list several masks on one element, separated by commas:
.glow {
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
background: conic-gradient(
#bc82f3,
#f5b9ea,
#8d9fff,
#ff6778,
#ffba71,
#c686ff,
#bc82f3
);
mask-image:
linear-gradient(to right, black, transparent),
linear-gradient(to left, black, transparent),
linear-gradient(to bottom, black, transparent),
linear-gradient(to top, black, transparent);
mask-size:
8vmin 100%,
8vmin 100%,
100% 8vmin,
100% 8vmin;
mask-position: left, right, top, bottom;
mask-repeat: no-repeat;
}The commas line up across the properties. The first gradient goes with the first size and the first position, the second with the second, and so on:
- a strip
8vminwide against the left wall, fading rightward - the same against the right wall, fading leftward
- a strip
8vmintall along the top, fading downward - the same along the bottom, fading upward
Where two stencils overlap, in the corners, their "show" adds together, so the corners catch light from both sides, which is what real light does.
This is also where the conic gradient pays off. The middle is gone, and each edge is showing a different color of the gradients: purple along the top, blue down the right, red and orange along the bottom.
In the playground, change every 8vmin to 30vmin to see the glow reach further in. Then comment out the to bottom line, along with its size and position, to see that each edge really is its own stencil.
Making it read as light
The glow is in the right place now, but it looks a little flat, more like a painted stripe than light. That's the fade's fault.
linear-gradient(to right, black, transparent) fades at a perfectly steady rate. Halfway across the strip, it's exactly half as strong. Real light doesn't behave like that. Look at the edge of a screen in a dark room: the glow stays strong right at the edge, drops off quickly, then leaves a long, faint haze that trails into the dark. Strong, a quick drop, a long tail. That shape is what makes something read as glowing rather than printed.
CSS can't draw a curve, but it can connect dots. A gradient accepts as many colour stops as you like, and it blends smoothly between each one. So we give it a handful of checkpoints along the way:
linear-gradient(to right,
rgba(0, 0, 0, 1) 0%,
rgba(0, 0, 0, 0.89) 15%,
rgba(0, 0, 0, 0.66) 30%,
rgba(0, 0, 0, 0.38) 45%,
rgba(0, 0, 0, 0.16) 60%,
rgba(0, 0, 0, 0.035) 75%,
transparent 100%)rgba(0, 0, 0, 1) is plain black, and the last number is how solid it is: 1 is fully solid, 0 is completely see-through. So each line reads as a checkpoint: "at 30% of the way across, be 66% solid."
We swap this in for each of the four plain gradients. Only mask-image changes; the size, position and repeat stay exactly as they were:
.glow {
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
background: conic-gradient(
#bc82f3,
#f5b9ea,
#8d9fff,
#ff6778,
#ffba71,
#c686ff,
#bc82f3
);
mask-image:
linear-gradient(to right,
rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.89) 15%, rgba(0, 0, 0, 0.66) 30%,
rgba(0, 0, 0, 0.38) 45%, rgba(0, 0, 0, 0.16) 60%, rgba(0, 0, 0, 0.035) 75%,
transparent 100%),
linear-gradient(to left,
rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.89) 15%, rgba(0, 0, 0, 0.66) 30%,
rgba(0, 0, 0, 0.38) 45%, rgba(0, 0, 0, 0.16) 60%, rgba(0, 0, 0, 0.035) 75%,
transparent 100%),
linear-gradient(to bottom,
rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.89) 15%, rgba(0, 0, 0, 0.66) 30%,
rgba(0, 0, 0, 0.38) 45%, rgba(0, 0, 0, 0.16) 60%, rgba(0, 0, 0, 0.035) 75%,
transparent 100%),
linear-gradient(to top,
rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.89) 15%, rgba(0, 0, 0, 0.66) 30%,
rgba(0, 0, 0, 0.38) 45%, rgba(0, 0, 0, 0.16) 60%, rgba(0, 0, 0, 0.035) 75%,
transparent 100%);
mask-size:
8vmin 100%,
8vmin 100%,
100% 8vmin,
100% 8vmin;
mask-position: left, right, top, bottom;
mask-repeat: no-repeat;
}The difference is subtle in a code block and obvious on screen. In the playground, change the four 0.035 checkpoints to 0.3. The faint tail turns into a visible haze, and the whole thing looks printed again. That one tiny number does a surprising amount of the work.
Making it move
The glow looks right, but it's frozen. The colours slowly slide around the edges of the screen.
The obvious move is to spin it. CSS animations let us describe a rotation once and have the browser play it forever:
.glow {
/* ...everything from before... */
animation: spin 5s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}@keyframes spin : start at 0°, end at a full turn, and the browser draws every frame in between. The animation line says which animation to run and how: take 5 seconds, move at a steady pace the whole way (linear), and loop forever (infinite). Since 360° looks identical to 0°, the loop has no visible jump.
Try it:
That's not what we wanted. The whole frame rotates around the screen, and the corners go bare.
The reason is that rotate turns an element and everything painted on it. The rainbow is on .glow, but so is the mask. Spin the element and the stencil spins with it, so the four edge strips stop hugging the edges and start orbiting the middle.
We want two things to behave differently: the stencil should stay perfectly still, while the colours move behind it. One element can't do both. So we split the work across two:
.glowkeeps the mask. It never moves. Think of it as a window frame taped to the screen..ring, a new element inside it, holds the rainbow and spins freely, like a coloured disc turning behind the frame.
A mask on a parent applies to everything inside it. So however the ring turns, we only ever see the parts of it that sit near the edges.
This is the first time the component changes. .glow gets a child:
import styles from "./style.module.css";
export function EdgeGlow() {
return (
<div className={styles.glow}>
<div className={styles.ring} />
</div>
);
}In the CSS, the background moves off .glow and onto .ring. .glow gains overflow: hidden, and the animation moves to .ring too:
.glow {
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
overflow: hidden;
mask-image: /* ...the four gradients from before... */;
mask-size:
8vmin 100%,
8vmin 100%,
100% 8vmin,
100% 8vmin;
mask-position: left, right, top, bottom;
mask-repeat: no-repeat;
}
.ring {
position: absolute;
top: 50%;
left: 50%;
width: 200vmax;
height: 200vmax;
margin-top: -100vmax;
margin-left: -100vmax;
background: conic-gradient(
#bc82f3,
#f5b9ea,
#8d9fff,
#ff6778,
#ffba71,
#c686ff,
#bc82f3
);
animation: spin 5s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}The ring has three things to get right: it has to sit exactly in the middle of the screen, be big enough to cover the screen at any angle, and spin.
Putting its middle on the screen's middle. position: absolute means "I'll place this myself," and top: 50% with left: 50% pushes the ring halfway down and halfway across.
There's a catch. top and left don't describe the box, they describe the gap before the box starts. top: 50% means "leave a gap from the top edge, 50% tall, then start the box." A box starts at its top-left corner, so it's the ring's corner that lands on the screen's centre, and the ring hangs down and to the right from there.
That matters because the conic gradient fans its colours out from the ring's own middle. With the ring hanging off to the bottom-right, the colours would fan out from the wrong spot, and the edges would be lopsided.
The negative margins fix it. A negative margin is a nudge in the opposite direction: we drag the ring up by half its height and left by half its width, so its middle lands exactly on the screen's centre. The two numbers are tied together: half of 200vmax is 100vmax. Change the size, and both margins have to change with it.
Making it big enough. vmax is the opposite of vmin: a percentage of the screen's longer side. So 200vmax is twice the screen's longest dimension, in both directions.
It has to be oversized because of what turning does to a square. Spin a square photo on a table and its corners sweep out a circle much wider than the photo. A screen-sized ring would leave the corners of the screen bare every time it turned, which is exactly what happened in the broken playground above. overflow: hidden on .glow then clips the part that hangs off the page, so it doesn't give us scrollbars.
Spinning it. The animation is the same one as before; it's just on the ring now. The ring turns, the frame doesn't, and the colours slide around the edges.
There's also a reason to spin the whole box instead of the colours inside it. The browser can't smoothly animate the numbers inside a gradient, because it doesn't know what they mean. Rotating an element, on the other hand, is something it's extremely good at. Turn the record, not the needle.
In the playground, try deleting the two margin lines. The colours go lopsided, because they're now fanning out from the bottom-right. Then try changing 200vmax to 100vmax (and both margins to -50vmax), and watch the corners as it turns.
Where CSS runs out
That's the whole resting glow: one sheet with a mask, one oversized ring spinning behind it. The preview at the top also fades in and ripples outward when you turn it on. That part is polish, and there's more than one good way to do it, so I'll leave it to you.
Shader does things differently. It runs a small program for every pixel on the screen, every frame. So instead of arranging layers until they look right, you describe the glow directly: how far this pixel is from the edge, how thick the band should be at this point around the rim, what colour belongs here. That's the next section.