EmpireUI
Get Pro
← Blog7 min read#glassmorphism#tooltip#accessibility

Frosted Glass Tooltip: Accessible Popover with Blur Effect

Build a frosted glass tooltip with backdrop-filter blur, proper ARIA roles, and Tailwind v4 utilities — no third-party libs, full keyboard support.

Frosted glass UI panel with blurred background and soft translucent overlay on a dark gradient

Why Tooltips Are Harder Than They Look

Honestly, tooltips have a reputation for being trivial — a title attribute and you're done. That reputation is wrong. The moment you need them to actually work across keyboard navigation, screen readers, mobile tap targets, and a design system that uses backdrop-filter: blur(12px), you're suddenly writing 80 lines of React and questioning your choices.

This article walks through building a frosted glass tooltip from scratch. We'll handle the blur effect with Tailwind v4 utilities, wire up the correct ARIA attributes, and make sure the component doesn't fight you when you start theming it. No wrapper libraries. Just a component you own.

If you've read our overview of what glassmorphism actually is, you already know the technique relies on backdrop-filter sitting on a semi-transparent surface. Tooltips are a perfect use case — small, short-lived, and they float above content where the blurred background reads well.

The Blur Effect: backdrop-filter vs box-shadow

There's a decision to make early. box-shadow gives you depth. backdrop-filter: blur() gives you the frosted glass look. They're not the same thing, and combining them without care produces a muddy result. For a tooltip sitting over a gradient or image background, backdrop-filter wins every time.

In Tailwind v4.0.2, you get backdrop-blur-sm (4px), backdrop-blur-md (12px), and backdrop-blur-lg (16px) out of the box. For a tooltip, backdrop-blur-md is usually the right call — enough to show the blur without making the content beneath unreadable. You'll also want a background color of roughly rgba(255,255,255,0.12) on dark themes and rgba(255,255,255,0.65) on light themes.

One thing people miss: backdrop-filter only works when the element itself has a background that isn't fully opaque. If you slap bg-white on it, the blur has nothing to show through. You need bg-white/10 or a custom rgba value. Also check browser support — Safari has required -webkit-backdrop-filter as a fallback since forever, though Tailwind handles that automatically.

Compare this to glassmorphism versus neumorphism — neumorphism leans on box-shadow insets to fake depth, while glassmorphism is literally about translucency. Tooltips styled with neumorphism tend to look like raised buttons, which is semantically confusing. Stick with glass for popovers.

Building the Tooltip Component in React

Here's a minimal but production-ready frosted glass tooltip. It uses a controlled visibility state, positions the popover above the trigger by default, and falls back gracefully when backdrop-filter isn't supported.

import { useState, useRef, useId } from 'react';

interface TooltipProps {
  content: string;
  children: React.ReactNode;
  placement?: 'top' | 'bottom';
}

export function GlassTooltip({ content, children, placement = 'top' }: TooltipProps) {
  const [visible, setVisible] = useState(false);
  const tooltipId = useId();
  const triggerRef = useRef<HTMLButtonElement>(null);

  const placementClasses =
    placement === 'top'
      ? 'bottom-full mb-2'
      : 'top-full mt-2';

  return (
    <span className="relative inline-flex items-center">
      <button
        ref={triggerRef}
        type="button"
        aria-describedby={visible ? tooltipId : undefined}
        onMouseEnter={() => setVisible(true)}
        onMouseLeave={() => setVisible(false)}
        onFocus={() => setVisible(true)}
        onBlur={() => setVisible(false)}
        className="focus:outline-none focus-visible:ring-2 focus-visible:ring-white/50 rounded"
      >
        {children}
      </button>

      {visible && (
        <span
          id={tooltipId}
          role="tooltip"
          className={`
            absolute left-1/2 -translate-x-1/2 ${placementClasses}
            z-50 w-max max-w-xs px-3 py-1.5
            text-sm text-white/90 font-medium
            rounded-lg
            border border-white/20
            bg-white/10
            backdrop-blur-md
            [-webkit-backdrop-filter:blur(12px)]
            shadow-[0_4px_24px_rgba(0,0,0,0.25)]
            pointer-events-none
            animate-in fade-in duration-150
          `}
        >
          {content}
          <span
            className={`
              absolute left-1/2 -translate-x-1/2
              ${
                placement === 'top'
                  ? 'top-full border-t-white/20 border-x-transparent border-b-transparent'
                  : 'bottom-full border-b-white/20 border-x-transparent border-t-transparent'
              }
              border-4 border-solid
            `}
            aria-hidden="true"
          />
        </span>
      )}
    </span>
  );
}

A few things worth calling out. The useId hook ensures the aria-describedby connection between trigger and tooltip is unique per instance, which matters when you render multiple tooltips on the same page. The pointer-events-none on the tooltip panel prevents the tooltip from intercepting mouse events and triggering a flicker loop. And the caret arrow is aria-hidden because screen readers don't need to announce a visual decoration.

ARIA Roles and Keyboard Accessibility

The role="tooltip" attribute is what makes the element a proper tooltip in the accessibility tree. Without it, screen readers see a random element appearing and disappearing with no semantic context. Pair it with aria-describedby on the trigger — pointing to the tooltip's id — and assistive technology will read the tooltip content when the trigger is focused.

One thing that trips developers up: aria-describedby should only be set when the tooltip is visible. If you always set it, some screen readers will attempt to announce the tooltip content even when it's hidden with CSS. The pattern in the component above — aria-describedby={visible ? tooltipId : undefined} — handles this correctly.

Should the tooltip be dismissible with Escape? For simple informational tooltips, no — they dismiss on blur, which is standard behavior. But if you're building something more like a popover (interactive content, links inside it), you should use role="dialog" instead of role="tooltip" and handle Escape explicitly with a keydown listener. The ARIA spec treats these as different patterns with different rules.

Tailwind v4 Utilities for Glass Styling

Tailwind v4 makes the glass effect significantly cleaner to write. The arbitrary value syntax handles the background: bg-[rgba(255,255,255,0.12)] or the shorthand opacity modifier bg-white/12. Same story for borders — border-white/20 gives you a 1px hairline that catches light just enough to define the edge of the floating panel.

The shadow is worth customizing. The default Tailwind shadow utilities use hard-coded colors that don't work well on translucent surfaces. Prefer an arbitrary shadow: shadow-[0_8px_32px_rgba(0,0,0,0.3)]. That 32px spread and 0.3 opacity hits the visual sweet spot — enough depth without making the tooltip feel like it's punching a hole in the page.

If you're wiring this up inside a larger design system and want consistent token values, check out how particles background effects handle layering — the z-index and compositing logic translates directly to tooltip positioning.

One last Tailwind-specific note: animate-in and fade-in require the tailwindcss-animate plugin in v4. If you haven't added it, swap those classes for a CSS transition on opacity and transform. It's two lines of CSS and avoids the dependency entirely.

Theming: Dark Mode and Color Scheme Variants

Glass tooltips look best on dark backgrounds, but your app probably needs both themes. The good news is Tailwind's dark: modifier handles this cleanly. On light mode, push the background opacity up to around 60-70% — bg-white/65 — so the text stays legible. On dark mode, drop it back down to 10-15% so the blur effect shows through.

/* If you prefer plain CSS for the glass effect */
.glass-tooltip {
  background: rgba(255, 255, 255, 0.12);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  border: 1px solid rgba(255, 255, 255, 0.18);
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.28);
  border-radius: 8px;
  padding: 6px 12px;
  color: rgba(255, 255, 255, 0.9);
  font-size: 0.875rem;
  pointer-events: none;
}

@media (prefers-color-scheme: light) {
  .glass-tooltip {
    background: rgba(255, 255, 255, 0.65);
    border-color: rgba(0, 0, 0, 0.08);
    color: rgba(0, 0, 0, 0.85);
    box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
  }
}

If you're managing theme state in React — say, through a useTheme hook or a theme toggle component — you'll want to drive the glass values via CSS custom properties instead of Tailwind classes. That way a single variable change cascades through every glass surface in your UI without re-rendering components.

Positioning Without JavaScript Libraries

What about edge cases where the tooltip clips off-screen? Most developers reach for Floating UI or Popper.js here. Those are solid libraries, but for a tooltip that only needs top/bottom placement and a horizontal center, you can handle it with pure CSS and a small useEffect.

The trick is reading getBoundingClientRect() on the trigger after the tooltip renders, comparing the tooltip's projected position against window.innerWidth and window.innerHeight, then flipping the placement state if needed. It's maybe 15 lines of logic. You get collision detection without pulling in a 10kb dependency.

If your design system eventually needs full directional placement — top, bottom, left, right, plus alignment variants — then yes, Floating UI is the right call. But don't add it preemptively. The best free glassmorphism components article covers several open-source options that already bundle positioning logic if you want to start from a higher floor.

Performance and Browser Support Notes

Here's something worth knowing: backdrop-filter triggers GPU compositing. Every element with an active blur creates a new compositing layer. On a page with 20 visible tooltips simultaneously — unlikely, but possible in dense data dashboards — you'll see GPU memory pressure. In practice, tooltips are rarely all visible at once, so this isn't usually a real problem. Just don't apply backdrop-filter to elements that are always rendered but hidden via opacity: 0.

Browser support as of late 2026: backdrop-filter is green across Chrome, Edge, Firefox, and Safari. The -webkit- prefix is still technically required for older Safari, but Tailwind's PostCSS pipeline adds it automatically. If you're writing raw CSS, add both. Firefox had a history of putting this behind a flag, but it's been enabled by default since Firefox 103.

One more edge case: if a parent element has overflow: hidden or transform: translate3d, backdrop-filter on a child element may not work as expected. The blur samples from what's behind the element in the stacking context, and certain transforms can break that. If your tooltip appears inside a modal or a transformed container, you may need to portal the tooltip to document.body to get clean blur rendering.

FAQ

Why isn't my backdrop-filter blur showing up in the tooltip?

Almost always one of two reasons. Either the tooltip's background is fully opaque (you need a semi-transparent value like bg-white/10, not bg-white), or a parent element has transform, filter, or will-change set, which creates a new stacking context that clips the backdrop sampling. Portal the tooltip to document.body to rule out the parent issue.

What's the difference between role="tooltip" and role="dialog" for popovers?

role="tooltip" is for short, non-interactive descriptive text. It's read passively by screen readers via aria-describedby. role="dialog" is for interactive content — links, buttons, forms — that the user needs to interact with. Tooltips dismiss on blur; dialogs need explicit close controls and focus trapping. Use the right one or assistive technology behavior will be wrong.

Does Tailwind v4 support `backdrop-blur-md` out of the box or do I need a plugin?

Yes, backdrop-blur-sm, backdrop-blur-md, backdrop-blur-lg, and backdrop-blur-xl are all built into Tailwind v4's default config. The -webkit-backdrop-filter prefix is added automatically by the PostCSS pipeline. You don't need any plugin for basic blur values.

How do I handle tooltip positioning when it clips near the viewport edge?

Read getBoundingClientRect() on the trigger element after mount, calculate where the tooltip would appear, then flip placement from top to bottom (or swap horizontal offset) if the projected bounds exceed window.innerWidth or window.innerHeight. For full directional control, Floating UI is the standard choice, but simple flip logic is maybe 15 lines without a dependency.

Can I animate the tooltip appearance without `tailwindcss-animate`?

Yes. Add a CSS transition directly: transition: opacity 150ms ease, transform 150ms ease; with opacity: 0; transform: translateY(4px); as the hidden state and opacity: 1; transform: translateY(0); as the visible state. Drive the class swap from React state. No plugin needed, and you get the same 150ms fade-in.

Will the frosted glass tooltip work inside a dark mode toggle setup?

Yes, but you'll get the best results using CSS custom properties for the glass values rather than hardcoded Tailwind classes. Set --glass-bg to rgba(255,255,255,0.12) on dark and rgba(255,255,255,0.65) on light, then reference that variable in your tooltip styles. When your theme toggle flips a data-theme attribute, the tooltip updates instantly without any React re-renders.

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

Read next

Glass Navigation Bar: Sticky Header with Backdrop BlurGlassmorphism Alert Banner: Announcement Bar with BlurTooltip vs Popover in React: When to Use Each, Full CodeImage Gallery with Lightbox: Accessible Photo Viewer in React