Skip to content

FreeIn reviewv1.0.0

Glass Tabs

A segmented control on glass with a thumb that slides with a small liquid overshoot.
npx shadcn@latest add @vibebox/glass-tabs

Controls

Tone

About

The smallest switch with the most feel: billing period, chart range, view mode. The thumb is itself glass, so it refracts the pill below it.

Use it when

  • Two to five mutually exclusive options.
  • Billing toggles, chart ranges, view switches.

Skip it when

  • Navigation between pages: use links.
  • More than five options.

Install

  1. 1Add the VibeBox registry to components.json:
    {
      "registries": {
        "@vibebox": "https://motion.vibeboxph.com/r/{name}.json"
      }
    }
  2. 2Add the component with the shadcn CLI:
    npx shadcn@latest add @vibebox/glass-tabs

AI prompt

Everything an AI coding tool needs to rebuild this component the way it looks here: colours, fonts, the art it draws, props and rules.

Read the prompt
Build the VibeBox Motion component "Glass Tabs" (Liquid glass) in a Next.js App Router project with Tailwind CSS v4.

What it is
- A segmented control on glass with a thumb that slides with a small liquid overshoot.
- A tab list on a glass pill. The selected thumb is a brighter second pane that slides by transform with a gentle overshoot; arrow keys, Home and End move the selection.

Install
- `npx shadcn@latest add @vibebox/glass-tabs`
- Registry: add `"@vibebox": "https://motion.vibeboxph.com/r/{name}.json"` to components.json, then run the command. The source lands in `components/vibebox/glass-tabs` and is yours to edit.

Visual spec
- Colours (the VibeBox spectrum, the same in light and dark):
    - --spectrum-1: #00c5fc (cyan)
    - --spectrum-2: #1d6ef0 (blue)
    - --spectrum-3: #5225ed (indigo)
    - --spectrum-4: #e434dc (magenta)
    - --spectrum-5: #fd3aa9 (pink)
    - --spectrum-6: #f98472 (coral)
    - --spectrum-7: #fcbe72 (amber)
- Ink surface (dark): background #05070c, card #0a0d15, text #f5f6fa, muted text #a3abbd, border rgba(255,255,255,0.08).
- Paper surface (light): background #f3f0ea, card #fbfaf7, text #0c0e14, muted text #4a4f5c, border rgba(12,14,20,0.12).
- Fonts:
    - Display and body: Figtree (400, 500, 600, 700), falling back to ui-sans-serif, system-ui.
    - Emphasis: Fraunces italic, for one or two words of a heading.
    - Labels and code: JetBrains Mono (400, 500), uppercase labels with 0.14em tracking.
- Shape: radius 8 to 12px on cards and panels; pills may be fully round. Hairline borders, soft glow instead of heavy shadow.
- Motion: animate transform and opacity only; fast ease-out (cubic-bezier(0.16, 1, 0.3, 1)), 140ms for micro-interactions, 320 to 520ms for reveals.

Art and media
- Built from HTML, CSS and inline SVG only; no image files or video.

Props
- `items`: `{ id: string; label: string }[]`. The tabs.
- `value`: `string`. The selected id when controlled.
- `defaultValue`: `string`. The first selection when uncontrolled.
- `onChange`: `(id: string) => void`. Called with the new id.
- `label`: `string` (default "Options"). Accessible name for the tab list.
- `tone`: `"auto" | "dark" | "light"` (default "auto"). Tints the glass and sets the text colour for the page behind it.
- `className`: `string`. Merged with cn(); sets the width and margins.

Behaviour and access
- Reduced motion: The pointer sheen and sliding are off; state changes are instant.
- Keyboard: Arrow keys, Home and End move the selection; one tab stop (roving tabindex).
- Screen readers: role=tablist with role=tab buttons and aria-selected.
- Touch: Targets are 36 px tall; tap selects.
- Budget: about 3 KB of JavaScript, WebGL none, GPU low. It pauses while off screen.

Theming
The surface reads `--g-tint`, `--g-edge`, `--g-ink`, `--g-blur` and `--g-radius` from its root; the demo backdrop uses `--spectrum-*`.

Notes
Add the VibeBox Motion Glass Tabs with `npx shadcn@latest add @vibebox/glass-tabs`. Use it for a two to five option switch such as billing period or chart range: pass `items`, control it with `value` and `onChange`, and give the list a `label`. Keep the roving tabindex, the arrow-key handling and the transform-only thumb.

Rules
- Keep the licence header on every installed file.
- Original code only: do not copy components from other libraries, and do not add GSAP.
- Use tokens for colour, type and radius; no hard-coded brand values.
- Server Components by default; `"use client"` only for the island.

Code

2 files
components/vibebox/glass-tabs/glass-tabs.client.tsx
/**
 * VibeBox Motion · Glass Tabs
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
"use client";

import { useId, useRef, useState, type KeyboardEvent } from "react";
import { cn } from "@/lib/utils";
import { Glass } from "@/lib/vibebox/glass";

export type GlassTab = { id: string; label: string };

export type GlassTabsProps = {
  /** The tabs, left to right. */
  items?: GlassTab[];
  /** The selected id, when you control it. */
  value?: string;
  /** The first selection when uncontrolled. */
  defaultValue?: string;
  onChange?: (id: string) => void;
  /** An accessible name for the tab list. */
  label?: string;
  tone?: "auto" | "dark" | "light";
  className?: string;
};

const DEFAULT_ITEMS: GlassTab[] = [
  { id: "day", label: "Day" },
  { id: "week", label: "Week" },
  { id: "month", label: "Month" },
  { id: "year", label: "Year" },
];

/**
 * A segmented control on glass. The selected thumb is a second, brighter glass pane that slides
 * with a small overshoot, by transform only; arrow keys, Home and End move the selection.
 */
export function GlassTabs({ items = DEFAULT_ITEMS, value, defaultValue, onChange, label = "Options", tone, className }: GlassTabsProps) {
  const [own, setOwn] = useState(defaultValue ?? items[0]?.id);
  const current = value ?? own;
  const index = Math.max(0, items.findIndex((item) => item.id === current));
  const base = useId();
  const buttons = useRef<(HTMLButtonElement | null)[]>([]);

  const select = (next: number) => {
    const target = items[(next + items.length) % items.length];
    if (!target) return;
    setOwn(target.id);
    onChange?.(target.id);
    buttons.current[(next + items.length) % items.length]?.focus();
  };
  const onKey = (event: KeyboardEvent) => {
    if (event.key === "ArrowRight" || event.key === "ArrowDown") select(index + 1);
    else if (event.key === "ArrowLeft" || event.key === "ArrowUp") select(index - 1);
    else if (event.key === "Home") select(0);
    else if (event.key === "End") select(items.length - 1);
    else return;
    event.preventDefault();
  };

  return (
    <Glass data-vb="glass-tabs" tone={tone} radius={999} still className={cn("inline-block p-1", className)}>
      <div role="tablist" aria-label={label} onKeyDown={onKey} className="relative grid" style={{ gridTemplateColumns: `repeat(${items.length}, minmax(0, 1fr))` }}>
        <span
          aria-hidden="true"
          className="pointer-events-none absolute inset-y-0 left-0 rounded-full border border-white/40 bg-gradient-to-b from-white/45 to-white/15 shadow-[inset_0_1px_0_rgb(255_255_255/0.7),0_6px_16px_-6px_rgb(0_0_0/0.45)] transition-transform duration-500 ease-[cubic-bezier(0.34,1.45,0.5,1)] motion-reduce:transition-none"
          style={{ width: `${100 / items.length}%`, transform: `translateX(${index * 100}%)` }}
        />
        {items.map((item, position) => (
          <button
            key={item.id}
            ref={(node) => {
              buttons.current[position] = node;
            }}
            id={`${base}-${item.id}`}
            role="tab"
            type="button"
            aria-selected={position === index}
            tabIndex={position === index ? 0 : -1}
            onClick={() => select(position)}
            className={cn(
              "relative z-10 h-9 rounded-full px-4 text-[0.88rem] font-medium transition-opacity focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring",
              position === index ? "opacity-100" : "opacity-65 hover:opacity-90",
            )}
          >
            {item.label}
          </button>
        ))}
      </div>
    </Glass>
  );
}
components/vibebox/glass-tabs/index.tsx
/**
 * VibeBox Motion · Glass Tabs
 * Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
 * https://motion.vibeboxph.com/license
 * Use it in unlimited personal, commercial and client projects.
 * Do not sell or redistribute it as a component, kit, template or builder.
 */
export * from "./glass-tabs.client";

Props

PropTypeDefaultDescription
items{ id: string; label: string }[]The tabs.
valuestringThe selected id when controlled.
defaultValuestringThe first selection when uncontrolled.
onChange(id: string) => voidCalled with the new id.
labelstring"Options"Accessible name for the tab list.
tone"auto" | "dark" | "light""auto"Tints the glass and sets the text colour for the page behind it.
classNamestring—Merged with cn(); sets the width and margins.

Accessibility and motion

Keyboard
Arrow keys, Home and End move the selection; one tab stop (roving tabindex).
Screen readers
role=tablist with role=tab buttons and aria-selected.
Touch
Targets are 36 px tall; tap selects.
Reduced motion
The pointer sheen and sliding are off; state changes are instant.
  • A glass control centre with four toggle tiles and two sliders

    A control-centre panel: glass toggle tiles and sliders that are real range inputs.

    Liquid glass · CSS

  • A glass confirmation dialog with two buttons over a dimmed colourful page

    A confirmation dialog on glass over a dimmed scrim, with focus kept inside and Escape to cancel.

    Liquid glass · CSS

  • A row of glass app icons, the middle one enlarged, over a gradient

    A floating dock of glass icons that swell toward the pointer, with labels and badges.

    Liquid glass · CSS

↑ ↓ to move↵ to openesc to close