Glassmorphism Tooltip: Frosted-Glass Floating Labels in React
Build frosted-glass tooltips in React with backdrop-filter blur, CSS variables, and accessible ARIA attributes — no third-party tooltip library needed.
Why Glassmorphism Tooltips Hit Different
Tooltips are boring. Most of them look like a gray <title> attribute that crawled out of 2003 and died on screen. Glassmorphism fixes that — not because it's trendy, but because the frosted-glass effect gives floating labels a visual hierarchy that actually makes sense on busy, dark backgrounds.
The trick is backdrop-filter: blur(). When your tooltip sits over a gradient hero section or a photo, blurring what's behind it creates depth that a solid background simply can't. The label feels like it's *floating*, not pasted on top. Honestly, once you've shipped one of these you'll find it hard to go back to a plain background: #333 tooltip.
That said, there's real nuance here. Support for backdrop-filter landed properly in Chrome 76 (2019) and Safari 9 already had it via -webkit-backdrop-filter. Firefox took until version 103 in 2022 to ship it unflagged. So in 2026 you're basically safe without a fallback — but it's still worth testing on your analytics' lowest-spec browsers.
Worth noting: glassmorphism doesn't just mean blur. The full effect needs a translucent background (rgba or a color with low alpha), a subtle border (1px solid rgba(255,255,255,0.2) is the classic), and enough contrast in your text that WCAG AA doesn't become a horror story. More on that below.
If you haven't explored the full component system yet, the glassmorphism components page on Empire UI is a solid starting point — it shows the full spectrum of surfaces this technique applies to, not just tooltips.
The Core CSS: Blur, Alpha, Border
Before touching React, nail the CSS. A glassmorphism tooltip is really just three properties doing the heavy lifting. Everything else is polish.
.glass-tooltip {
/* Frosted glass base */
background: rgba(255, 255, 255, 0.12);
backdrop-filter: blur(12px) saturate(180%);
-webkit-backdrop-filter: blur(12px) saturate(180%);
/* Border gives the 'glass edge' illusion */
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
/* Readable text on translucent bg */
color: #fff;
font-size: 13px;
line-height: 1.4;
padding: 6px 10px;
/* Floating feel */
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.15);
}The blur(12px) is a reasonable default — you might push it to 16px on very busy backgrounds, but past 20px performance tanks on mid-range mobile. The saturate(180%) makes colors underneath pop slightly, which reinforces the glass metaphor. Drop that if your background is grayscale.
One more thing — the inset 0 1px 0 rgba(255,255,255,0.15) on the box-shadow. That's a top-edge highlight. It's subtle but it's what separates a tooltip that looks like a glass pane from one that just looks like a semi-transparent div with a border. Small detail, big difference.
In practice, you'll also want a CSS custom property for the blur amount so you can tune it per-theme without touching component files. Something like --glass-blur: 12px at the :root level keeps you sane when a designer decides the hero section needs 8px and the sidebar needs 16px.
Building the React Component
Let's build the actual thing. A minimal glassmorphism tooltip in React needs: positioning logic, show/hide state, ARIA attributes, and the CSS above. We'll use a custom hook to keep the component clean.
import { useState, useRef, useId } from 'react';
import './GlassTooltip.css';
interface GlassTooltipProps {
content: string;
children: React.ReactNode;
placement?: 'top' | 'bottom' | 'left' | 'right';
}
export function GlassTooltip({
content,
children,
placement = 'top',
}: GlassTooltipProps) {
const [visible, setVisible] = useState(false);
const tooltipId = useId();
return (
<span
className="glass-tooltip-wrapper"
onMouseEnter={() => setVisible(true)}
onMouseLeave={() => setVisible(false)}
onFocus={() => setVisible(true)}
onBlur={() => setVisible(false)}
aria-describedby={visible ? tooltipId : undefined}
>
{children}
{visible && (
<span
id={tooltipId}
role="tooltip"
className={`glass-tooltip glass-tooltip--${placement}`}
>
{content}
</span>
)}
</span>
);
}The useId() hook (React 18+) gives you a stable, SSR-safe ID without reaching for Math.random(). That ID wires the trigger to the tooltip via aria-describedby, which is what screen readers actually use — not title, not alt, aria-describedby. Don't skip it.
The onFocus/onBlur pair is required for keyboard users. Mouse-only tooltips fail WCAG 2.1 criterion 1.4.13. Quick aside: if you're not testing with a keyboard at least once per PR, you're going to have a bad time when an accessibility audit lands.
/* GlassTooltip.css */
.glass-tooltip-wrapper {
position: relative;
display: inline-flex;
}
.glass-tooltip {
position: absolute;
z-index: 1000;
white-space: nowrap;
pointer-events: none;
/* Core glass effect */
background: rgba(255, 255, 255, 0.12);
backdrop-filter: blur(12px) saturate(180%);
-webkit-backdrop-filter: blur(12px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
color: #fff;
font-size: 13px;
padding: 6px 10px;
box-shadow:
0 4px 24px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.15);
}
/* Placement variants */
.glass-tooltip--top { bottom: calc(100% + 8px); left: 50%; transform: translateX(-50%); }
.glass-tooltip--bottom { top: calc(100% + 8px); left: 50%; transform: translateX(-50%); }
.glass-tooltip--left { right: calc(100% + 8px); top: 50%; transform: translateY(-50%); }
.glass-tooltip--right { left: calc(100% + 8px); top: 50%; transform: translateY(-50%); }Animated Entry with CSS Transitions
Instant appear/disappear feels cheap. A 150ms fade-in with a tiny upward translate makes the tooltip feel intentional. The trick is using opacity + transform so you stay on the compositor thread and avoid layout jank.
@keyframes glass-tooltip-in {
from {
opacity: 0;
transform: translateX(-50%) translateY(4px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
.glass-tooltip--top {
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
animation: glass-tooltip-in 150ms ease-out forwards;
}You'll need separate @keyframes for each placement since the transform base differs. It's a little repetitive but much safer than JavaScript-driven animations for something this lightweight. Reaching for Framer Motion for a tooltip is overkill — and prefers-reduced-motion is easier to handle in pure CSS.
@media (prefers-reduced-motion: reduce) {
.glass-tooltip {
animation: none;
}
}That four-line block is all you need for motion accessibility. Users with vestibular disorders get an immediate-appear tooltip; everyone else gets the smooth fade. Look, this kind of thing takes 30 seconds and it ships a meaningfully more accessible component.
Tailwind Version (If You're Not Writing Raw CSS)
If your project uses Tailwind, you don't need the CSS file at all. The classes map pretty directly, though you'll need to enable backdropBlur in your config if you're on Tailwind v3. It's on by default in v4.
function GlassBadge({ label }: { label: string }) {
return (
<span className="
bg-white/10
backdrop-blur-md
border border-white/20
rounded-lg
text-white text-xs
px-2.5 py-1.5
shadow-[0_4px_24px_rgba(0,0,0,0.3)]
pointer-events-none
whitespace-nowrap
">
{label}
</span>
);
}The bg-white/10 is background: rgba(255,255,255,0.1) — Tailwind's opacity modifier. backdrop-blur-md maps to blur(12px). The shadow uses the arbitrary value syntax because Tailwind's default shadow scale doesn't include the inset highlight we want.
Worth noting: if you're mixing this tooltip with a dark color scheme, swap bg-white/10 for bg-neutral-900/40 and border-white/20 for border-white/10. Same glass effect, darker base — better on light-background pages. You can explore the full dark-surface approach over at the glassmorphism generator which lets you dial these values live.
One more thing — pointer-events: none on the tooltip itself (that's pointer-events-none in Tailwind) is non-optional. Without it, the tooltip intercepts hover events when the cursor moves toward it, causing a flicker loop that will drive users insane.
Accessibility and Contrast: The Real Gotcha
Here's the part most glassmorphism tutorials skip entirely, and it bites people in production. Translucent white text on a blur-blended background is a contrast nightmare. The actual rendered contrast ratio depends on what's *behind* the tooltip, which changes as the user scrolls. That's not something you can audit statically.
The pragmatic fix is to not rely purely on the frosted layer for contrast. Add a subtle text-shadow to your tooltip text — text-shadow: 0 1px 2px rgba(0,0,0,0.6) pushes the text off the glass surface and gives you legible text regardless of what's behind it. It's cheating, but it works.
.glass-tooltip {
/* ... existing styles ... */
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
font-weight: 500; /* 400 is too thin on frosted surfaces */
}For maximum legibility, use font-weight: 500 or higher. Regular-weight text at 13px on a 12% white background is borderline unreadable for users with any degree of contrast sensitivity loss. Honestly, font-weight: 600 at 13px often looks better anyway — it makes the tooltip feel more intentional, like a label rather than a whisper.
Quick aside: if you want to go further into accessibility on translucent surfaces, the WCAG 2.2 documentation specifically addresses "non-text contrast" and adjacent color ratios. Worth a read. Also — the box shadow generator on Empire UI has a live preview mode that helps you sanity-check your tooltip's shadow values against different backgrounds before committing them.
Positioning Edge Cases: Viewport Clipping
Simple position: absolute tooltips get clipped at the viewport edge. Put a tooltip-top on a button in the top navigation bar and it disappears off-screen. This is a real bug, not a theoretical one — it usually shows up in the first round of QA.
The lightweight fix without pulling in Floating UI or Popper.js is a getBoundingClientRect check on mount. If the tooltip's top would go negative, flip it to bottom. Same logic for left/right. It covers 90% of real-world cases.
import { useState, useRef, useLayoutEffect, useId } from 'react';
export function SmartGlassTooltip({ content, children }: GlassTooltipProps) {
const [visible, setVisible] = useState(false);
const [placement, setPlacement] = useState<'top' | 'bottom'>('top');
const wrapperRef = useRef<HTMLSpanElement>(null);
const tooltipId = useId();
useLayoutEffect(() => {
if (!visible || !wrapperRef.current) return;
const rect = wrapperRef.current.getBoundingClientRect();
// If less than 60px above, flip to bottom
setPlacement(rect.top < 60 ? 'bottom' : 'top');
}, [visible]);
return (
<span
ref={wrapperRef}
className="glass-tooltip-wrapper"
onMouseEnter={() => setVisible(true)}
onMouseLeave={() => setVisible(false)}
onFocus={() => setVisible(true)}
onBlur={() => setVisible(false)}
aria-describedby={visible ? tooltipId : undefined}
>
{children}
{visible && (
<span id={tooltipId} role="tooltip"
className={`glass-tooltip glass-tooltip--${placement}`}>
{content}
</span>
)}
</span>
);
}The useLayoutEffect runs synchronously after DOM mutation but before paint, so you avoid the one-frame flicker you'd get with useEffect. Small but important. If you need full four-direction auto-placement and sub-pixel positioning, that's when Floating UI earns its keep — but for most UIs this inline check is plenty.
That said, if your app has complex scroll containers with overflow: hidden, getBoundingClientRect will give you viewport-relative coordinates that don't account for the clip boundary. In those cases you genuinely need a portal (ReactDOM.createPortal) to render the tooltip at the document root. It's more code but it's the only clean solution.
FAQ
Yes — Chrome 76+, Safari 9+ (with -webkit- prefix), and Firefox 103+ all support it. In 2026 you're covering 98%+ of users without a fallback.
Use role='tooltip', wire it to the trigger via aria-describedby, handle onFocus/onBlur for keyboard users, and add text-shadow to compensate for variable background contrast.
Only if you need sub-pixel accuracy and full auto-placement in complex scroll containers. For most tooltips, a simple getBoundingClientRect viewport check handles edge cases without the dependency.
Yes — bg-white/10, backdrop-blur-md, and border-white/20 map directly to the glassmorphism effect. Enable backdropBlur in your Tailwind v3 config, or it's on by default in v4.