Skip to content
goodtabs UI
BrowseBrowse components

Glass tabs

Tabs on a pill track with a pane of glass that slides under the active tab.

npx shadcn@latest add https://ui.goodtabs.co/r/glass-tabs.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/glass-tabs

12 builds today

Three range tabs over a photo, readable at 250px.

With panels

Three services, all healthy. Last deploy 14 minutes ago.

Tabs with content panels on a solid backing.

Usage

import { GlassTabs, GlassTabsList, GlassTabsTrigger, GlassTabsContent } from "@/components/ui/glass-tabs"

<GlassTabs defaultValue="day">
  <GlassTabsList aria-label="Range">
    <GlassTabsTrigger value="day">Day</GlassTabsTrigger>
    <GlassTabsTrigger value="week">Week</GlassTabsTrigger>
  </GlassTabsList>
</GlassTabs>

Props

PropTypeDefaultDescription
value, defaultValue, onValueChangeradix Tabs propsControlled or uncontrolled, on GlassTabs.
aria-labelstringName the GlassTabsList.

Source

glass-tabs/glass-tabs.tsx
"use client";

import * as React from "react";

import { GlassSurface } from "@/components/ui/glass-surface";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";

/** Root. Radix tabs through the shadcn tabs: value, defaultValue and onValueChange work as usual. */
function GlassTabs({ className, ...props }: React.ComponentProps<typeof Tabs>) {
  return <Tabs data-slot="glass-tabs" className={cn("gap-3", className)} {...props} />;
}

/**
 * The track is a frosted pill and the indicator is a pane of glass that slides under the active tab.
 * The indicator is a sibling of the triggers, so they stay plain buttons for radix and screen readers.
 */
function GlassTabsList({ className, children, ...props }: React.ComponentProps<typeof TabsList>) {
  const ref = React.useRef<HTMLDivElement>(null);
  const [indicator, setIndicator] = React.useState<{ x: number; width: number } | null>(null);

  React.useEffect(() => {
    const list = ref.current;
    if (!list) return;
    const measure = () => {
      const active = list.querySelector<HTMLElement>('[role="tab"][data-state="active"]');
      setIndicator(active ? { x: active.offsetLeft, width: active.offsetWidth } : null);
    };
    measure();
    const resize = new ResizeObserver(measure);
    resize.observe(list);
    for (const tab of list.querySelectorAll<HTMLElement>('[role="tab"]')) resize.observe(tab);
    // data-state flips on the trigger when the value changes, so watch it instead of threading value through.
    const mutation = new MutationObserver(measure);
    mutation.observe(list, { attributes: true, subtree: true, attributeFilter: ["data-state"] });
    return () => {
      resize.disconnect();
      mutation.disconnect();
    };
  }, []);

  return (
    <TabsList
      ref={ref}
      variant="line"
      data-slot="glass-tabs-list"
      className={cn(
        "relative isolate h-9 gap-0 rounded-full border bg-background/60 p-1 text-foreground group-data-horizontal/tabs:h-9 data-[variant=line]:rounded-full [@media(prefers-reduced-transparency:reduce)]:bg-muted",
        className,
      )}
      {...props}
    >
      {indicator && (
        <GlassSurface
          aria-hidden="true"
          radius={14}
          rim={8}
          strength={18}
          blur={3}
          data-slot="glass-tabs-indicator"
          className="absolute inset-y-1 left-0 -z-10 rounded-full bg-foreground/15 transition-[transform,width] duration-150 ease-out motion-reduce:transition-none"
          style={{ width: indicator.width, transform: `translateX(${indicator.x}px)` }}
        />
      )}
      {children}
    </TabsList>
  );
}

function GlassTabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsTrigger>) {
  return (
    <TabsTrigger
      data-slot="glass-tabs-trigger"
      className={cn(
        "h-7 rounded-full px-3 text-foreground after:hidden hover:text-foreground data-[state=active]:text-foreground dark:text-foreground/75 dark:hover:text-foreground dark:data-[state=active]:text-foreground data-active:border-transparent data-active:bg-transparent data-active:shadow-none dark:data-active:border-transparent dark:data-active:bg-transparent",
        className,
      )}
      {...props}
    />
  );
}

function GlassTabsContent({ className, ...props }: React.ComponentProps<typeof TabsContent>) {
  return <TabsContent data-slot="glass-tabs-content" className={className} {...props} />;
}

export { GlassTabs, GlassTabsContent, GlassTabsList, GlassTabsTrigger };
components/ui/glass-surface.tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";

type GlassSurfaceProps = React.ComponentProps<"div"> & {
  /** Corner radius in pixels. Keep it equal to the rounded class on the surface, so the rim follows the edge. */
  radius?: number;
  /** Width of the bent rim in pixels. */
  rim?: number;
  /** How far the rim pushes what is behind it, in pixels. */
  strength?: number;
  /** Frost behind the glass, in pixels. */
  blur?: number;
};

/** Distance from a point to the edge of a rounded rectangle centred on the origin. Negative is inside. */
function roundedBoxDistance(x: number, y: number, halfWidth: number, halfHeight: number, radius: number) {
  const qx = Math.abs(x) - halfWidth + radius;
  const qy = Math.abs(y) - halfHeight + radius;
  return Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) + Math.min(Math.max(qx, qy), 0) - radius;
}

/**
 * Paints a displacement map: red and green hold how far each pixel is pushed, 128 is no push.
 * Inside the rim the push points along the edge normal and falls off with a quarter circle, the
 * profile of a lens edge, so the centre of the glass stays flat.
 */
function paintRefractionMap(width: number, height: number, radius: number, rim: number) {
  const canvas = document.createElement("canvas");
  canvas.width = width;
  canvas.height = height;
  const context = canvas.getContext("2d");
  if (!context) return null;
  const image = context.createImageData(width, height);
  const halfWidth = width / 2;
  const halfHeight = height / 2;
  for (let py = 0; py < height; py++) {
    for (let px = 0; px < width; px++) {
      const x = px + 0.5 - halfWidth;
      const y = py + 0.5 - halfHeight;
      const edge = roundedBoxDistance(x, y, halfWidth, halfHeight, radius);
      const depth = Math.min(Math.max(-edge / rim, 0), 1);
      const bend = 1 - Math.sqrt(1 - (1 - depth) * (1 - depth));
      // The edge normal, by central differences on the distance field.
      const nx = roundedBoxDistance(x + 1, y, halfWidth, halfHeight, radius) - roundedBoxDistance(x - 1, y, halfWidth, halfHeight, radius);
      const ny = roundedBoxDistance(x, y + 1, halfWidth, halfHeight, radius) - roundedBoxDistance(x, y - 1, halfWidth, halfHeight, radius);
      const length = Math.hypot(nx, ny) || 1;
      const at = (py * width + px) * 4;
      image.data[at] = 128 - (nx / length) * bend * 127;
      image.data[at + 1] = 128 - (ny / length) * bend * 127;
      image.data[at + 2] = 128;
      image.data[at + 3] = 255;
    }
  }
  context.putImageData(image, 0, 0);
  return canvas.toDataURL();
}

/**
 * A pane of glass. Chromium bends what is behind it with an SVG filter; every other browser gets the
 * frosted fallback, which is the same surface without the bent rim. Either way the pane keeps enough
 * fill to hold text, and it turns solid for people who ask for less transparency.
 */
function GlassSurface({
  radius = 12,
  rim = 14,
  strength = 40,
  blur = 6,
  className,
  style,
  children,
  ...props
}: GlassSurfaceProps) {
  const id = React.useId().replace(/[^a-zA-Z0-9]/g, "");
  const surface = React.useRef<HTMLDivElement>(null);
  const [map, setMap] = React.useState<string | null>(null);

  React.useEffect(() => {
    const el = surface.current;
    if (!el) return;
    // backdrop-filter with a url() only renders in Chromium, and other browsers can parse it and draw nothing.
    if (!("userAgentData" in navigator) || !CSS.supports("backdrop-filter", "url(#a)")) return;
    const paint = () => {
      const width = Math.round(el.clientWidth);
      const height = Math.round(el.clientHeight);
      if (width > 0 && height > 0) setMap(paintRefractionMap(width, height, radius, rim));
    };
    const observer = new ResizeObserver(paint);
    observer.observe(el);
    return () => observer.disconnect();
  }, [radius, rim]);

  const refracting = map !== null;
  return (
    <div
      ref={surface}
      data-slot="glass-surface"
      data-refracting={refracting || undefined}
      className={cn(
        "relative border bg-background/50 shadow-[inset_0_1px_0_0_color-mix(in_oklab,var(--foreground)_8%,transparent)] backdrop-blur-md contrast-more:bg-background [@media(prefers-reduced-transparency:reduce)]:bg-background [@media(prefers-reduced-transparency:reduce)]:backdrop-blur-none",
        className,
      )}
      style={refracting ? { ...style, backdropFilter: `url(#${id})` } : style}
      {...props}
    >
      {refracting ? (
        <svg aria-hidden="true" width="0" height="0" className="absolute">
          <filter id={id} x="0" y="0" width="100%" height="100%" colorInterpolationFilters="sRGB">
            <feImage href={map ?? undefined} preserveAspectRatio="none" result="map" />
            <feGaussianBlur in="SourceGraphic" stdDeviation={blur} result="frost" />
            <feDisplacementMap in="frost" in2="map" scale={strength} xChannelSelector="R" yChannelSelector="G" />
          </filter>
        </svg>
      ) : null}
      {children}
    </div>
  );
}

export { GlassSurface };

For AI agents

Hand your agent this page, the markdown at /components/glass-tabs/prompt.md, or the Copy AI prompt button above.

When to use

  • A two to four way switch that floats over a photo or video
  • A chart or map overlay that needs a range switch

When not to use

  • On flat surfaces: use segmented-tabs
  • For long labels or more than five options

Agent notes

  • Built on the shadcn tabs with the line variant, so radix keys, roles and focus all work. The indicator is an aria-hidden sibling
  • The indicator slides in 150ms on transform and width, and does not slide under reduced motion
  • Panels are not glass. Give GlassTabsContent its own solid backing, as the second demo does