EmpireUI
Get Pro
← Blog8 min read#neon text#react#css animation

Neon Text Effect in React: Animated Glow with CSS and Framer Motion

Build a pulsing neon text effect in React using CSS text-shadow and Framer Motion. Covers flicker, color shifts, and reusable component patterns with real code.

Glowing neon pink and purple text effect on dark background

Why Neon Text Still Works in 2026

Neon text had a moment in the late 2010s, got overused, and then — quietly — came back. Not as kitsch. As a deliberate aesthetic choice in cyberpunk, vaporwave, and dark-mode UIs where you actually want that electric, high-contrast energy. Done right, it reads as intentional. Done lazily, it looks like a Bootstrap theme from 2014.

The good news: CSS has gotten powerful enough that you don't need a canvas, WebGL, or a 300 KB library to pull this off. text-shadow stacked three or four layers deep, combined with a simple @keyframes animation, gives you 90% of the effect. Framer Motion handles the other 10% — the subtle flicker, the entrance animation, the state-driven color shift when a user hovers.

In practice, the hardest part isn't the CSS. It's keeping it performant. Text-shadow is paint-heavy, and if you're animating it on every frame on a low-end device, you'll hit jank fast. We'll cover how to avoid that.

Quick aside: if you're building a full dark-mode UI and want more than just text effects, check out the cyberpunk style hub — it's got pre-built components that pair naturally with the kind of glow effects we're building here.

The CSS Foundation: Stacking text-shadow Layers

Neon glow in CSS is basically just text-shadow abused in the best way. A single shadow looks flat. Stack four to six layers with increasing blur radius and the same hue, and suddenly it reads as emissive light. Here's the core pattern:

.neon-text {
  color: #fff;
  text-shadow:
    0 0 4px #fff,
    0 0 10px #fff,
    0 0 20px #e60073,
    0 0 40px #e60073,
    0 0 80px #e60073,
    0 0 120px #e60073;
}

The first two layers (4px and 10px blur) are white — that's the bright hot core. The subsequent layers are your color (pink in this case, #e60073), increasing in blur. That spread is what creates the glow falloff. You can swap #e60073 for cyan (#00f5ff), green (#39ff14), or purple (#bf00ff) depending on your palette. The white core stays white regardless.

Worth noting: the color property itself matters a lot here. Set it to #fff for maximum contrast. If you set it to your glow color, the text gets washed out at large font sizes. Keep the core white, let the shadows carry the hue.

For the animation, a simple @keyframes pulse between two opacity values on the outer shadow layers is enough: ``css @keyframes neon-pulse { 0%, 100% { text-shadow: 0 0 4px #fff, 0 0 10px #fff, 0 0 20px #e60073, 0 0 40px #e60073, 0 0 80px #e60073; } 50% { text-shadow: 0 0 4px #fff, 0 0 10px #fff, 0 0 18px #e60073, 0 0 30px #e60073, 0 0 50px #e60073; } } .neon-text { animation: neon-pulse 2s ease-in-out infinite; } `` That 50% keyframe dials back the outer blur from 80px to 50px. Subtle. That's the point.

Building the NeonText React Component

Let's wrap this into a reusable component. The goal is something you can drop anywhere — <NeonText color="cyan">HELLO</NeonText> — without copying shadow strings around your codebase.

// components/NeonText.tsx
import React from 'react';
import styles from './NeonText.module.css';

type NeonColor = 'pink' | 'cyan' | 'green' | 'purple';

const GLOW_COLORS: Record<NeonColor, string> = {
  pink: '#e60073',
  cyan: '#00f5ff',
  green: '#39ff14',
  purple: '#bf00ff',
};

interface NeonTextProps {
  children: React.ReactNode;
  color?: NeonColor;
  tag?: keyof JSX.IntrinsicElements;
  className?: string;
}

export function NeonText({
  children,
  color = 'pink',
  tag: Tag = 'span',
  className = '',
}: NeonTextProps) {
  const glowColor = GLOW_COLORS[color];

  const style: React.CSSProperties = {
    color: '#fff',
    textShadow: [
      '0 0 4px #fff',
      '0 0 10px #fff',
      `0 0 20px ${glowColor}`,
      `0 0 40px ${glowColor}`,
      `0 0 80px ${glowColor}`,
    ].join(', '),
  };

  return (
    <Tag style={style} className={`neon-pulse ${className}`}>
      {children}
    </Tag>
  );
}

The tag prop is important. You want to render an h1 for hero text, a span inline, or a p for body — don't hardcode the element. That's the difference between a component and a one-off hack.

Add the pulse animation in a global CSS or module file: ``css /* NeonText.module.css or globals.css */ @keyframes neon-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.85; } } .neon-pulse { animation: neon-pulse 2.4s ease-in-out infinite; } ` Animating opacity` on the element instead of the shadow values is a key performance win — opacity changes are compositor-layer operations in most browsers, which means no repaint. You still get a visible breathing effect because the glow is rendered from the element's current opacity.

Honestly, this alone covers most use cases. But if you want flicker, entrance animations, or interactive hover states, that's where Framer Motion earns its place in the bundle.

Adding Framer Motion: Flicker, Entrance, and Hover

Framer Motion's animate prop accepts any animatable CSS property. The tricky thing with textShadow is that it's a string — you can't interpolate individual layers. So the cleanest approach for flicker is animating filter: brightness() or opacity on the element, rather than trying to morph the shadow string itself.

// components/NeonTextAnimated.tsx
import { motion } from 'framer-motion';

const flickerSequence = {
  opacity: [1, 0.8, 1, 0.6, 1, 0.9, 1],
};

export function NeonTextAnimated({ children, color = 'pink' }: NeonTextProps) {
  const glowColor = GLOW_COLORS[color];

  return (
    <motion.span
      style={{
        color: '#fff',
        display: 'inline-block',
        textShadow: [
          '0 0 4px #fff',
          '0 0 10px #fff',
          `0 0 20px ${glowColor}`,
          `0 0 40px ${glowColor}`,
          `0 0 80px ${glowColor}`,
        ].join(', '),
      }}
      initial={{ opacity: 0, scale: 0.95 }}
      animate={{
        opacity: 1,
        scale: 1,
        ...flickerSequence,
      }}
      transition={{
        opacity: {
          duration: 0.3,
          times: [0, 0.1, 0.2, 0.4, 0.6, 0.8, 1],
          repeat: Infinity,
          repeatDelay: 4,
        },
        scale: { duration: 0.5, ease: 'easeOut' },
      }}
      whileHover={{
        filter: 'brightness(1.4)',
        scale: 1.02,
        transition: { duration: 0.15 },
      }}
    >
      {children}
    </motion.span>
  );
}

The repeatDelay: 4 is intentional. A flicker every 4 seconds reads like a real neon tube — not a broken scrolling ticker. If you flicker too often, users perceive it as a bug or an accessibility hazard. That 4-second delay keeps it atmospheric.

The whileHover with brightness(1.4) is a nice touch. The glow intensifies when the user mouses over, which works especially well on interactive elements like nav links or CTAs. filter: brightness is GPU-composited in Chromium 120+ (released late 2023), so it won't cause a layout repaint.

One more thing — Framer Motion's AnimatePresence pairs perfectly with this if you're rendering neon text conditionally. Wrap it and add an exit prop with opacity: 0 and you get a fade-out that mirrors the entrance: ``tsx import { AnimatePresence, motion } from 'framer-motion'; <AnimatePresence> {isVisible && ( <motion.div key="neon-title" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.4 }} > <NeonTextAnimated color="cyan">GAME OVER</NeonTextAnimated> </motion.div> )} </AnimatePresence> ``

Color-Shifting Neon with CSS Custom Properties

Static neon is fine. Neon that slowly cycles through hues is something else entirely. You can build this with CSS custom properties and @property — the Houdini API that lets you animate typed custom properties. Browser support as of 2026 is solid: Chromium 85+, Firefox 128+, Safari 16.4+.

@property --neon-hue {
  syntax: '<angle>';
  initial-value: 330deg;
  inherits: false;
}

@keyframes hue-rotate-neon {
  from { --neon-hue: 330deg; }
  to   { --neon-hue: 690deg; } /* 330 + 360 = full cycle */
}

.neon-rainbow {
  --neon-color: hsl(var(--neon-hue) 100% 60%);
  color: #fff;
  text-shadow:
    0 0 4px #fff,
    0 0 10px #fff,
    0 0 20px var(--neon-color),
    0 0 40px var(--neon-color),
    0 0 80px var(--neon-color);
  animation: hue-rotate-neon 6s linear infinite;
}

Without @property, the browser can't interpolate --neon-hue between keyframes — it just snaps at the end. With @property, it animates smoothly. That 6-second duration is slow enough to feel organic, not like a cheap rainbow spinner.

In React, you can drive the hue from state if you want user-controlled color-shifting — a color picker that sets a CSS variable on the element directly via the style prop. Something like style={{ '--neon-hue': ${hue}deg } as React.CSSProperties}. Cast to React.CSSProperties since TypeScript doesn't know about your custom properties.

If you're building a full aesthetic system around this, look at what's available in the vaporwave hub. The color palette there — corals, magentas, electric purples — maps directly onto neon shadow hues, and you'd save yourself a lot of color-picking time.

Performance and Accessibility Considerations

Here's the thing nobody talks about in neon effect tutorials: text-shadow triggers a paint on every animation frame if you animate its values directly. On a mid-range Android device, that's a fast path to 20fps UI. The rule is simple — animate opacity, transform, or filter on the element itself. Keep text-shadow static. The visual result is nearly identical and the performance is night and day.

Always wrap Framer Motion animations in a prefers-reduced-motion check. Users with vestibular disorders find flickering text genuinely uncomfortable — don't skip this: ``tsx import { useReducedMotion } from 'framer-motion'; function NeonTextSafe({ children, color = 'pink' }: NeonTextProps) { const shouldReduce = useReducedMotion(); return ( <motion.span animate={shouldReduce ? {} : { opacity: [1, 0.8, 1] }} transition={{ repeat: Infinity, repeatDelay: 4, duration: 0.3 }} style={buildNeonStyle(color)} > {children} </motion.span> ); } ``

Contrast is the other concern. White text on a dark background with a glow effect typically passes WCAG AA (4.5:1 ratio for normal text). But if you're using a colored text-shadow on a colored background, measure it. The glow can make text feel more readable than it actually is — the visual brightness isn't the same as contrast ratio.

Look, neon effects live and die by their context. On a true #000000 or very dark background (#0a0a0f is a solid choice — 10px shy of pure black avoids the flat look), the glow reads brilliantly. On a #1a1a2e dark navy? Even better — the cool undertone makes pink and cyan glow pop harder. Put it on a #f5f5f5 background and you'll wonder why you bothered.

Putting It Together: A Hero Section Example

Here's a real-world pattern — a hero section with a neon headline, subtitle, and CTA. This is the kind of thing you'd actually ship on a gaming app landing page, an event page, or a cyberpunk-themed SaaS: ``tsx import { motion } from 'framer-motion'; import { NeonTextAnimated } from '@/components/NeonTextAnimated'; export function NeonHero() { return ( <section className="min-h-screen flex flex-col items-center justify-center bg-[#060010] px-4" > <motion.p className="text-sm tracking-[0.3em] text-purple-400 uppercase mb-4" initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }} > Enter the grid </motion.p> <h1 className="text-6xl md:text-8xl font-black text-center mb-6"> <NeonTextAnimated color="cyan">BEYOND</NeonTextAnimated> <br /> <NeonTextAnimated color="pink">THE VOID</NeonTextAnimated> </h1> <motion.p className="text-gray-400 text-lg max-w-md text-center mb-10" initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.8 }} > Build interfaces that glow. Deploy in seconds. </motion.p> <motion.button className="px-8 py-4 border border-cyan-500 text-cyan-400 text-sm tracking-widest uppercase" whileHover={{ backgroundColor: 'rgba(0, 245, 255, 0.1)', boxShadow: '0 0 30px rgba(0, 245, 255, 0.4)', }} transition={{ duration: 0.2 }} > Get Started </motion.button> </section> ); } ``

The two NeonTextAnimated components stagger naturally because of the repeatDelay offset — they won't flicker in sync, which is exactly what you want. Synchronized flicker looks mechanical. Offset flicker looks like real tubes.

That border-cyan-500 button with a hover boxShadow glow is worth noticing. You can extend the neon aesthetic to non-text elements just as easily — borders, buttons, cards. The box shadow generator is useful here if you want to dial in the exact spread and blur values visually before committing to code.

This whole setup is about 4 KB before tree-shaking, assuming Framer Motion is already in your bundle. If it's not in your project yet, the react-animation-framer-motion article covers the setup and key concepts before you go deep on effects like this.

FAQ

Does animating text-shadow directly hurt performance?

Yes, significantly. Text-shadow changes trigger a repaint on every frame. Animate opacity or filter: brightness() on the element instead — those are compositor-layer operations and won't cause repaints.

Can I use neon text on a light background?

It doesn't really work. The glow effect relies on dark backgrounds to create the luminance contrast that makes it readable. On light backgrounds the shadows just look muddy.

How do I make the flicker look realistic instead of mechanical?

Add a repeatDelay of 4-6 seconds so it's infrequent, and use an opacity sequence like [1, 0.8, 1, 0.6, 1] instead of a smooth sine wave. Real neon tubes flicker irregularly and briefly.

Is this accessible for users with motion sensitivities?

Use Framer Motion's useReducedMotion() hook and skip all animations when it returns true. Flickering text is a known trigger for vestibular disorders, so this isn't optional.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Aurora UI: How to Build Gradient Aurora Effects in CSS & ReactGradient Button in React + Tailwind: Hover, Focus and Active StatesFramer Motion Advanced: Layout Animations, Shared Elements, useAnimateFramer Motion Layout Animations: shared layout, AnimatePresence