Neon Text Glow in CSS: Text-Shadow Techniques for Dark UIs
Build neon text glow effects with CSS text-shadow layers, Tailwind utilities, and React — no canvas, no images. Real code for dark UIs that actually look good.
Why Neon Glow Text Is Harder Than It Looks
Honestly, most neon text effects on the web look terrible — either too faint to notice or so heavy they burn your eyes. Getting it right takes more than slapping a single text-shadow on an element and calling it done.
The reason good neon glow works is layering. Real neon signs don't just emit a single point of light; they bleed color outward in concentric halos of decreasing intensity. CSS text-shadow lets you stack multiple shadow declarations on one property, separated by commas. That's the entire secret.
This article covers the exact layering technique, how to wire it into Tailwind v4.0.2 with arbitrary values, and a React component you can drop into any dark UI. We'll also touch on performance, animation, and the cases where SVG filters make more sense than pure CSS.
How CSS text-shadow Stacking Actually Works
Each layer in text-shadow takes four values: offset-x offset-y blur-radius color. Offset zero means the shadow sits directly beneath the text. The blur radius is where the glow comes from — a large blur with zero offset spreads color outward in every direction.
Stack three to five layers with increasing blur radii and you get the halo effect. The innermost layer should be near-white or a fully saturated version of your chosen color. Each outer layer drops in opacity or shifts slightly warmer/cooler. Here's a minimal cyan neon example:
.neon-cyan {
color: #e0ffff;
text-shadow:
0 0 4px #fff,
0 0 11px #fff,
0 0 19px #fff,
0 0 40px #0ff,
0 0 80px #0ff,
0 0 90px #0ff,
0 0 100px #0ff,
0 0 150px #0ff;
}That's eight layers. For subtler effects in product UIs you can cut it to three or four. The 4px and 11px white layers give the bright core. The 40–150px cyan layers produce the atmospheric bleed you associate with neon signage.
Building a Reusable NeonText React Component
Rather than copy-pasting those shadow strings everywhere, wrap them in a small React component. Accept a color prop and map it to a preset shadow stack. You can extend this with a intensity prop later.
// components/NeonText.tsx
import { CSSProperties, ReactNode } from 'react'
type NeonColor = 'cyan' | 'magenta' | 'lime' | 'orange'
const GLOW_MAP: Record<NeonColor, string> = {
cyan: '0 0 4px #fff,0 0 11px #fff,0 0 40px #0ff,0 0 80px #0ff',
magenta: '0 0 4px #fff,0 0 11px #fff,0 0 40px #f0f,0 0 80px #f0f',
lime: '0 0 4px #fff,0 0 11px #fff,0 0 40px #0f0,0 0 80px #0f0',
orange: '0 0 4px #fff,0 0 11px #ffe,0 0 40px #f80,0 0 80px #f80',
}
interface NeonTextProps {
children: ReactNode
color?: NeonColor
as?: keyof JSX.IntrinsicElements
className?: string
}
export function NeonText({
children,
color = 'cyan',
as: Tag = 'span',
className = '',
}: NeonTextProps) {
const style: CSSProperties = { textShadow: GLOW_MAP[color] }
return (
<Tag className={`text-white ${className}`} style={style}>
{children}
</Tag>
)
}Usage is clean: <NeonText color="magenta" as="h1">EMPIRE UI</NeonText>. The as prop lets you render it as any heading or paragraph without extra wrappers. If you're already using glassmorphism cards, this pairs well — neon headings over a frosted glass panel is a combination that works at any scale.
Tailwind v4.0.2 Arbitrary Values for Neon Glow
Tailwind doesn't ship neon text-shadow utilities out of the box, but arbitrary values let you write them inline. It's verbose, no question, but it works without a plugin for quick one-off headers.
<h1 class="text-white [text-shadow:0_0_4px_#fff,0_0_40px_#0ff,0_0_80px_#0ff]">
Neon Heading
</h1>For anything you're repeating more than twice, move it into a Tailwind plugin or a CSS layer. In your tailwind.config.ts with v4.0.2, use addUtilities to register .neon-cyan, .neon-magenta etc. That keeps your markup clean and ensures PurgeCSS/content scanning doesn't strip the classes.
One thing to know: Tailwind's JIT compiler treats underscores in arbitrary values as spaces, so 0_0_40px_#0ff becomes 0 0 40px #0ff at build time. It works, it's just a bit ugly to read in source.
Animating Neon Flicker with CSS Keyframes
Static neon looks fine. Flickering neon looks alive. The trick is animating text-shadow opacity with a randomized feel — achieved by using a multi-keyframe @keyframes block with irregular stops.
@keyframes neon-flicker {
0%, 19%, 21%, 23%, 25%, 54%, 56%, 100% {
text-shadow:
0 0 4px #fff,
0 0 11px #fff,
0 0 40px #0ff,
0 0 80px #0ff;
}
20%, 24%, 55% {
text-shadow: none;
}
}
.neon-flicker {
color: #e0ffff;
animation: neon-flicker 2.5s infinite alternate;
}Those off states at 20%, 24%, and 55% simulate a sign that's losing its gas. The alternate direction means it never resets abruptly. Keep the animation duration between 2s and 4s — faster than that reads as broken, slower than that feels like a loading bug.
Don't reach for JavaScript for this. CSS animations run on the compositor thread and won't janky-fy your React renders. This is one case where CSS truly handles it better. See also how particles backgrounds need the JS-side work because CSS can't manage hundreds of moving elements — neon text is the opposite problem.
When to Use SVG Filters Instead of text-shadow
Here's the thing: text-shadow has real limits. It can't do colored inner glows, it doesn't respect transparency in the text itself, and on very thin typefaces at small sizes it can look muddy. SVG feGaussianBlur filters give you more control.
An SVG filter approach wraps your text in an <svg> element and applies a filter definition. You can combine feColorMatrix, feGaussianBlur, and feMerge to build effects that text-shadow simply can't replicate — including glow that follows the exact shape of each letterform's stroke rather than the bounding box.
The downside is markup weight and the fact that SVG text doesn't flow inline with HTML text the same way. For headlines and hero sections, SVG filters are worth it. For body text, UI labels, and anything you need in a loop, stick with text-shadow. It's faster to write, faster to render, and easier to debug.
Also worth considering: if you're building a full dark-mode UI with multiple glow elements, check how your theme toggle affects them. Neon glow on light backgrounds looks like a mistake, so you'll want those shadow utilities gated behind a .dark class or a prefers-color-scheme: dark media query.
Performance Considerations for Glow-Heavy Interfaces
Rendering text-shadow with large blur radii is GPU-bound. On a single heading it's totally fine. On 200 list items all with 150px blur layers, you'll see compositing cost climb on low-end Android devices. Profile before you ship.
Chrome DevTools' Layers panel will show you whether your neon elements are getting their own compositing layers. If you're animating text-shadow, the browser *will* promote the element — that's expected. If you're using static glow on dozens of cards, consider whether will-change: filter or transform: translateZ(0) helps or hurts in your specific case. It varies.
One practical cap: keep blur radii under 80px for anything with more than 10 instances on screen. You can fake the same visual with three layers maxing out at 60px and slightly higher opacity on the outer layers. Most users won't see the difference. The comparison to glassmorphism vs neumorphism applies here too — visual richness always has a rendering cost, and the right call is knowing when you're past the point of diminishing returns.
Dark UI Color Pairing for Neon Text
Neon glow only reads as neon when the background is genuinely dark. #0a0a0a or #050510 backgrounds make cyan and magenta pop. On a #1e1e2e base (popular in terminal-inspired UIs), bump your outer glow radii up by about 20% — the slightly lighter background absorbs more of the spread.
Color pairing matters more than most developers think. Cyan (#00ffff) and magenta (#ff00ff) are complementary and work well together. Lime (#00ff00) is classic but reads as aggressive — use it sparingly as an accent, not a heading color. Orange neon (rgba(255, 140, 0, 1)) works for warnings or CTAs without feeling alarming.
Avoid white neon glow on dark backgrounds unless you're going for a cold, clinical look. The white glow layers in the examples above are intentional — they represent the hot core of the light source — but making the outer layers white too just looks like a bad drop-shadow. Keep those outer halos saturated.
FAQ
Three to five layers covers most use cases. One near-white tight layer for the bright core, one medium blur in your saturated color, and one or two large-radius outer layers for the atmospheric bleed. Eight layers is the upper end — beyond that the returns diminish and you're just adding GPU cost.
Yes. Animating text-shadow doesn't trigger layout or paint in most browsers — it goes through compositing. Use a CSS @keyframes animation rather than JS requestAnimationFrame to keep it off the main thread. Avoid animating text-shadow alongside properties that do trigger layout, like font-size or padding.
High-DPI screens render blur radii differently — what looks like 40px on a 1x display renders much crisper on a 2x device, which can make the glow look smaller and less atmospheric. You may need to increase your outer radius values by 30–50% and test on an actual retina device, not just browser DevTools device emulation.
It does, but thin strokes produce a narrower base for the glow to emerge from. The result can look faint or uneven. Try bumping the font-weight to at least 500 for neon headings, or switch to an SVG filter approach which better follows thin stroke geometry.
In your tailwind.config.ts, use the addUtilities function inside a plugin: plugin(({ addUtilities }) => { addUtilities({ '.neon-cyan': { textShadow: '0 0 4px #fff, 0 0 40px #0ff, 0 0 80px #0ff' } }) }). Then use class="text-white neon-cyan" in your markup. Make sure the class name appears somewhere in your content globs or it'll get purged.
The static glow itself isn't motion, so you don't need to strip it for reduced-motion users. However, the flicker animation absolutely should be disabled. Wrap the flicker keyframes in a @media (prefers-reduced-motion: no-preference) block so users who've opted out of motion don't see the animation.