Text Effects
NeonTube
Bent glass tube on metal clips, grey and dead at rest. Power up and each letter strikes on its own, then one worn letter keeps stuttering.
Download
- React
- Tailwind CSS v4
- motion
components/ui/neon-tube.tsx
"use client";
import { useEffect, useRef } from "react";
import { animate, motion, useInView, useMotionValue, useReducedMotion } from "motion/react";
type NeonTubeProps = {
text: string;
className?: string;
/** Gas colour. */
color?: string;
/** Power. Set false to switch the sign off. */
on?: boolean;
/** Index of the worn letter that keeps stuttering, -1 for none. */
stutter?: number;
};
const SCRIPT = "font-['Segoe_Script','Snell_Roundhand','Brush_Script_MT',cursive]";
type LetterProps = { ch: string; delay: number; lit: boolean; worn: boolean; color: string; reduce: boolean };
function Letter({ ch, delay, lit, worn, color, reduce }: LetterProps) {
const glow = useMotionValue(0);
useEffect(() => {
if (reduce) {
glow.set(lit ? 1 : 0);
return;
}
if (!lit) {
const off = animate(glow, 0, { duration: 0.18 });
return () => off.stop();
}
let loop: ReturnType<typeof animate> | undefined;
const strike = animate(glow, [0, 1, 0.1, 0, 0.85, 0.15, 1], { duration: 0.8, delay, times: [0, 0.14, 0.26, 0.5, 0.64, 0.78, 1] });
if (worn) {
strike.then(() => {
loop = animate(glow, [1, 0.2, 1, 0.45, 1], { duration: 0.45, repeat: Infinity, repeatDelay: 4.5 });
});
}
return () => {
strike.stop();
loop?.stop();
};
}, [lit, delay, worn, reduce, glow]);
return (
<>
<span style={{ color: "rgba(255,255,255,0.2)", textShadow: "0 1px 0 rgba(255,255,255,0.28), 0 -1px 0 rgba(0,0,0,0.65)" }}>{ch}</span>
<motion.span
className="absolute inset-0"
style={{
opacity: glow,
color: "#fffdf8",
textShadow: `0 0 0.03em #fff, 0 0 0.1em ${color}, 0 0 0.26em ${color}, 0 0 0.6em ${color}, 0 0 1.1em ${color}`,
}}
>
{ch}
</motion.span>
</>
);
}
/* NeonTube: bent glass tube on clips, grey and dead at rest. Power up and each letter strikes on its own, one worn letter keeps stuttering. */
export function NeonTube({ text, className = SCRIPT, color = "#ff3d81", on = true, stutter = 3 }: NeonTubeProps) {
const reduce = useReducedMotion() ?? false;
const box = useRef<HTMLSpanElement>(null);
const inView = useInView(box, { amount: 0.6 });
const lit = on && inView;
const words = text.split(" ");
return (
<span ref={box} className={`relative inline-block ${className}`}>
<span className="sr-only">{text}</span>
<motion.span
aria-hidden
className="pointer-events-none absolute -inset-[0.7em] rounded-full"
style={{ background: `radial-gradient(closest-side, ${color}44, transparent)` }}
initial={{ opacity: 0 }}
animate={{ opacity: lit ? 1 : 0 }}
transition={{ duration: 0.5, delay: lit ? 0.5 : 0 }}
/>
<span aria-hidden className="relative">
{words.map((word, w) => {
const start = words.slice(0, w).join(" ").length + (w ? 1 : 0);
return (
<span key={w}>
{w > 0 && " "}
<span className="inline-block whitespace-nowrap">
{[...word].map((ch, c) => {
const i = start + c;
return (
<span key={i} className="relative inline-block">
<Letter ch={ch} lit={lit} color={color} reduce={reduce} worn={i === stutter} delay={((i * 7) % 11) * 0.09} />
{i % 3 === 1 && (
<span className="absolute bottom-[0.27em] left-1/2 h-[0.07em] w-[0.34em] -translate-x-1/2 rounded-full bg-[#8d9099] shadow-[0_1px_1px_rgba(0,0,0,0.7)]" />
)}
</span>
);
})}
</span>
</span>
);
})}
</span>
</span>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/neon-tube.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/neon-tube-demo.tsx
"use client"; import { useState } from "react"; import { NeonTube } from "@/components/ui/neon-tube"; export default function NeonTubeExample() { const [on, setOn] = useState(true); return ( <div className="@container grid h-full place-items-center p-6" style={{ backgroundColor: "#17131a", backgroundImage: "linear-gradient(#0c0a0e 2px, transparent 2px), linear-gradient(90deg, #0c0a0e 2px, transparent 2px), linear-gradient(90deg, #0c0a0e 2px, transparent 2px)", backgroundSize: "100% 30px, 72px 60px, 72px 60px", backgroundPosition: "0 0, 0 0, 36px 30px", }} > <div className="text-center"> <NeonTube text="Open late" on={on} className="font-['Segoe_Script','Snell_Roundhand','Brush_Script_MT',cursive] text-[min(14cqw,76px)]" /> <button type="button" onClick={() => setOn((v) => !v)} className="mt-8 block w-full text-xs text-white/50 hover:text-white"> {on ? "Switch off" : "Switch on"} </button> </div> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| text | string | — | Text on the sign. |
| color | string | "#ff3d81" | Gas colour. |
| on | boolean | true | Power. Set false to switch the sign off. |
| stutter | number | 3 | Index of the worn letter, -1 for none. |
| className | string | script font | Replaces the font. Thin, rounded type looks most like tube. |