Neon Button in CSS: Glowing Border, Pulsing Light, Hover Bloom
Build a neon button in pure CSS — glowing borders, keyframe pulse, hover bloom — with no JS and full browser support from 2023 onward.
Why Neon Buttons Still Work
Neon didn't die with the '80s. It came back hard around 2020 and it's been gaining ground ever since — cyberpunk aesthetics, dark-mode-first design, gaming dashboards, SaaS tools that want to feel premium without looking corporate. The glow effect triggers something almost pavlovian: your eye reads it as interactive before your brain even registers the shape.
Honestly, a well-executed neon button is one of the fastest ways to make a dark UI feel deliberate rather than lazy. But most tutorials stop at box-shadow and call it done. That's half the story. The full effect requires layering: border color, box-shadow spread, text glow, and optionally a keyframe pulse — each targeting a different visual layer so the result reads as real light, not a filter.
What you won't need for any of this is JavaScript. Pure CSS handles the glow, the pulse, and the hover bloom without a single event listener. That matters for performance, especially on lower-end mobile devices where JS-driven animations can skip frames.
Quick aside: if you want a broader aesthetic context for neon effects, check the vaporwave and y2k style hubs — both lean heavily into this kind of chromatic intensity.
The Anatomy of a CSS Glow
Before writing a single line, you need to understand what box-shadow is actually doing here. It's not one shadow — it's three or four stacked shadows with different spread radii. The innermost one is tight (2–4px spread), the middle one bleeds to about 10–20px, and the outer halo goes up to 40px or more. Together they fake the photon scatter you'd see around a real neon tube.
There's also text-shadow for the label itself. Skip that and your button looks like a glowing box with flat text inside, which is wrong. Text needs to emit the same color light as its container. Use a low blur (4–8px) at full opacity — don't go soft here or it just looks unfocused.
Color choice matters more than you'd think. Saturated hues at full saturation (hsl(180 100% 50%)) glow naturally; desaturated or dark colors do not — you're fighting the physics of how light works. Cyan, electric blue, hot pink, and acid green are the canonical choices because they sit near 100% saturation on the spectrum. You can of course tint them, but don't kill the chroma.
Worth noting: box-shadow doesn't clip to border-radius, so you get a nice soft oval bloom on rounded buttons for free. Set border-radius: 6px or go full pill with border-radius: 9999px — both work.
Base Neon Button: The Code
Here's the foundation. This is a single-class button with no preprocessor, no framework, no build step. Copy it, drop it in, and it works from Chrome 105 onward.
.btn-neon {
/* layout */
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.6em 1.6em;
border-radius: 6px;
border: 2px solid hsl(180 100% 50%);
/* typography */
font-family: inherit;
font-size: 1rem;
font-weight: 600;
letter-spacing: 0.05em;
color: hsl(180 100% 85%);
text-shadow:
0 0 4px hsl(180 100% 70%),
0 0 12px hsl(180 100% 55%);
/* background */
background: transparent;
cursor: pointer;
/* glow: tight inner → mid bloom → outer halo */
box-shadow:
0 0 4px hsl(180 100% 50%),
0 0 12px hsl(180 100% 50%),
0 0 32px hsl(180 100% 40%),
inset 0 0 8px hsl(180 100% 50% / 0.15);
transition: box-shadow 0.25s ease, color 0.25s ease;
}The inset shadow is optional but adds depth — it makes the button face look faintly lit from within rather than just outlined. Drop it if you want a crisper look. The outer halo at 32px spread is what bleeds onto the surrounding dark background and sells the illusion.
Now the hover state. Don't just scale up the same values — add a fourth shadow layer and brighten the text:
.btn-neon:hover {
color: hsl(180 100% 95%);
text-shadow:
0 0 6px hsl(180 100% 80%),
0 0 18px hsl(180 100% 65%),
0 0 36px hsl(180 100% 50%);
box-shadow:
0 0 6px hsl(180 100% 55%),
0 0 18px hsl(180 100% 50%),
0 0 40px hsl(180 100% 45%),
0 0 80px hsl(180 100% 35%),
inset 0 0 12px hsl(180 100% 50% / 0.25);
}That 80px outer bloom on hover is the "bloom" effect — it's aggressive, but on a dark background it's what makes the button feel like it's actually emitting photons. If your dark background is #0a0a0a or similar, 80px is fine. On a mid-gray background you'd dial this back to 40–50px.
Adding the Pulse Animation
A static glow is nice. A pulsing glow is hypnotic. The keyframe approach here is dead simple — you're just oscillating the box-shadow spread values between two states. The trick is keeping the inner shadows stable and only breathing the outer halo. That way it reads as a natural flicker, not a strobe.
@keyframes neon-pulse {
0%, 100% {
box-shadow:
0 0 4px hsl(180 100% 50%),
0 0 12px hsl(180 100% 50%),
0 0 32px hsl(180 100% 40%),
inset 0 0 8px hsl(180 100% 50% / 0.15);
}
50% {
box-shadow:
0 0 4px hsl(180 100% 55%),
0 0 20px hsl(180 100% 55%),
0 0 55px hsl(180 100% 45%),
0 0 90px hsl(180 100% 30% / 0.6),
inset 0 0 12px hsl(180 100% 55% / 0.2);
}
}
.btn-neon {
/* ... existing styles ... */
animation: neon-pulse 2.4s ease-in-out infinite;
}2.4 seconds feels natural — long enough not to be annoying, short enough to feel alive. Go below 1.5s and it starts reading as an error state. Go above 4s and users won't notice it's animating. That said, on hover you probably want to pause the pulse and snap to the full bloom state instead:
.btn-neon:hover {
animation-play-state: paused;
/* hover styles from previous section */
}In practice, this approach avoids the jarring jump that happens when a keyframe is mid-cycle and the hover state kicks in — pausing freezes the current value, and then transition on box-shadow handles the smooth interpolation to the hover state. It's a small detail but it makes a big difference in how polished the component feels.
Color Variants: Pink, Purple, Acid Green
Cyan is the default neon color but you'd probably want at least two or three variants for a real design system. The cleanest approach is CSS custom properties scoped to a modifier class — no duplicating the entire ruleset for each hue.
.btn-neon {
--neon-hue: 180; /* cyan */
--neon-sat: 100%;
--neon-l: 50%;
border-color: hsl(var(--neon-hue) var(--neon-sat) var(--neon-l));
color: hsl(var(--neon-hue) var(--neon-sat) 85%);
text-shadow:
0 0 4px hsl(var(--neon-hue) var(--neon-sat) 70%),
0 0 12px hsl(var(--neon-hue) var(--neon-sat) var(--neon-l));
box-shadow:
0 0 4px hsl(var(--neon-hue) var(--neon-sat) var(--neon-l)),
0 0 12px hsl(var(--neon-hue) var(--neon-sat) var(--neon-l)),
0 0 32px hsl(var(--neon-hue) var(--neon-sat) calc(var(--neon-l) - 10%)),
inset 0 0 8px hsl(var(--neon-hue) var(--neon-sat) var(--neon-l) / 0.15);
}
/* Variants */
.btn-neon--pink { --neon-hue: 320; }
.btn-neon--purple { --neon-hue: 270; }
.btn-neon--green { --neon-hue: 130; }
.btn-neon--amber { --neon-hue: 38; }Four variants, four lines. The keyframe animation and hover state stay unchanged because they reference the same custom properties. This is also why HSL beats hex here — you can't do arithmetic on #00ffff but you can absolutely do calc(var(--neon-l) - 10%).
Look, amber neon is underused. It reads warmer, less aggressive, and it works really well next to dark warm-brown backgrounds if you're going for a vintage bar sign rather than a gaming rig aesthetic. Don't sleep on it.
For more complex gradient variants — especially multi-color rainbow neon — head over to the gradient generator and export the background: linear-gradient(...) value, then apply it as a border via the border-image trick or a pseudo-element background clip.
Accessibility and Dark Mode Considerations
Here's the thing most neon button tutorials skip entirely: contrast. A cyan text label on a near-black background often fails WCAG AA at normal font sizes. hsl(180 100% 85%) as your text color on #0a0a0a hits about 12:1 contrast — that's fine. But if you drop text lightness to 50% to make it look more "neon," you're probably at 4–5:1, which is borderline. Run it through a contrast checker before shipping.
Reduced motion is the other thing you need to handle. Some users have prefers-reduced-motion: reduce set for medical reasons — vestibular disorders, epilepsy. A pulsing animation that runs indefinitely is exactly the kind of thing that can cause real problems.
@media (prefers-reduced-motion: reduce) {
.btn-neon {
animation: none;
}
}That's two lines. No excuse not to include them. The static glow still looks great without the pulse — you lose the animation but the button is still visually distinct and on-theme.
One more thing — focus states. Don't let the glow eat your outline. Either keep the default browser outline (it'll look off with the neon aesthetic) or replace it explicitly: .btn-neon:focus-visible { outline: 2px solid hsl(var(--neon-hue) var(--neon-sat) 80%); outline-offset: 4px; }. Keyboard users need to see where focus is, and the neon glow alone doesn't cut it from a WCAG perspective because it can be too diffuse to track.
Putting It All Together in a Component
If you're in a React or Next.js project, wrapping this in a component is 20 lines of code and nothing fancy. Here's a minimal version that handles variants and the as prop for semantic flexibility:
type NeonVariant = 'cyan' | 'pink' | 'purple' | 'green' | 'amber';
interface NeonButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: NeonVariant;
as?: React.ElementType;
}
export function NeonButton({
variant = 'cyan',
as: Tag = 'button',
className = '',
children,
...props
}: NeonButtonProps) {
return (
<Tag
className={`btn-neon btn-neon--${variant} ${className}`}
{...props}
>
{children}
</Tag>
);
}Pass as="a" when you need a link that looks like a button, as="div" if you're doing something weird with a custom click handler. The CSS classes do the heavy lifting — the component is just a thin wrapper that handles the variant prop mapping.
If you want production-ready glowing components without writing all of this from scratch, Empire UI has neon and cyberpunk style variants already built, tested, and accessible. Worth a look before you re-invent the wheel, especially if you need the button to integrate with a broader dark-UI component system.
The full CSS for this component — base, variants, hover, pulse, reduced motion, focus — is about 80 lines. That's a reasonable investment for something that legitimately makes your dark UI look premium. And because it's pure CSS, it adds zero runtime weight to your JS bundle.
FAQ
Technically yes, but it won't read as neon — it'll just look like a colored shadow. Neon glows need a dark or very deep background (ideally below #1a1a1a) to scatter correctly. On light backgrounds, use a solid colored button instead.
No. box-shadow is a compositing-only property — it doesn't affect the element's layout box or trigger reflow. Animating it is cheaper than animating width or padding, though the browser still needs to repaint. Use will-change: box-shadow if you notice jank on lower-end devices.
Most mobile screens have lower peak brightness than desktop monitors, so the glow spread needs to be tighter. Try halving your outer box-shadow spread value (e.g., 80px → 40px) and bumping color lightness up by 5–10% on touch screens using a media query.
Use border: 2px solid <color> plus box-shadow with no background: transparent on the element. The inset shadow subtly illuminates the button face from within, which is enough to make it look like the border is the only light source.