Glass Tab Bar

Floating glass capsule with a pill morphing between tabs.

SwiftUI: TabView (iOS 26)

Installation

npx shadcn@latest add https://cupertino-ui.baltoon.jp/r/glass-tab-bar.json

Requires the theme tokens — see Installation if this is your first component.

Source

"use client";

import * as React from "react";

import { Glass } from "@/components/ui/glass";
import { cn } from "@/lib/utils";

/**
 * The iOS 26 floating Liquid Glass tab bar: a capsule that hovers
 * above content with a brighter glass pill sliding between tabs
 * (glass morphing between positions, per the Liquid Glass spec).
 */
type Item = { value: string; icon?: React.ReactNode; label: React.ReactNode };

function GlassTabBar({
  className,
  items,
  value: valueProp,
  defaultValue,
  onValueChange,
  variant = "regular",
  ...props
}: Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> & {
  items: Item[];
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  variant?: "regular" | "clear";
}) {
  const [internal, setInternal] = React.useState(
    defaultValue ?? items[0]?.value
  );
  const value = valueProp ?? internal;
  const refs = React.useRef(new Map<string, HTMLButtonElement>());
  const [pill, setPill] = React.useState<{ left: number; width: number }>();

  const measure = React.useCallback(() => {
    const el = refs.current.get(value);
    if (el) setPill({ left: el.offsetLeft, width: el.offsetWidth });
  }, [value]);

  React.useLayoutEffect(() => {
    measure();
  }, [measure]);

  React.useEffect(() => {
    window.addEventListener("resize", measure);
    return () => window.removeEventListener("resize", measure);
  }, [measure]);

  const select = (v: string) => {
    if (valueProp === undefined) setInternal(v);
    onValueChange?.(v);
  };

  return (
    <Glass
      variant={variant}
      data-slot="glass-tab-bar"
      role="tablist"
      className={cn("inline-flex rounded-full p-1", className)}
      {...props}
    >
      {/* Sliding glass pill */}
      {pill ? (
        <span
          aria-hidden
          data-slot="glass-tab-bar-pill"
          className="absolute top-1 bottom-1 -z-[1] rounded-full bg-white/70 shadow-[inset_0_0_0_0.5px_rgba(255,255,255,0.5),0_2px_8px_rgba(0,0,0,0.15)] transition-[left,width] duration-350 ease-[cubic-bezier(0.34,1.3,0.64,1)] dark:bg-white/20"
          style={{ left: pill.left, width: pill.width }}
        />
      ) : null}

      {items.map((item) => {
        const active = item.value === value;
        return (
          <button
            key={item.value}
            ref={(el) => {
              if (el) refs.current.set(item.value, el);
            }}
            type="button"
            role="tab"
            aria-selected={active}
            onClick={() => select(item.value)}
            className={cn(
              "flex h-11 min-w-[64px] cursor-default select-none flex-col items-center justify-center gap-0.5 rounded-full px-4 outline-none transition-colors duration-200 focus-visible:ring-[3px] focus-visible:ring-blue/40 [&_svg]:size-5 [&_svg]:shrink-0",
              active ? "text-label" : "text-label/60"
            )}
          >
            {item.icon}
            <span className="text-caption-2 font-medium">{item.label}</span>
          </button>
        );
      })}
    </Glass>
  );
}

export { GlassTabBar };