Skip to content
Suluu

FluidTabs

A compact tablist that gives the active destination room to speak. Its circle opens into a pill on a soft spring, the label arrives just after the shape begins moving, and the neighboring tabs quietly make space. The active tab takes its own accent, and a single sheen of light travels the revealed label as it opens.

Preview

Click a tab or use the arrow keys
Inbox content

Installation

Registry — recommended

Copies the component into your configured shadcn UI directory and installs Motion.

npx shadcn@latest add https://suluu.site/r/fluid-tabs.json

npm package

For centralized upgrades, install the optional ESM package and configure Tailwind source detection as shown below.

pnpm add suluu motion

Usage

Supply your own icon for every tab and give the tablist a stable accessible name. Use item-level accent colors for distinct destinations, or omit them to share the theme accent.

Registry
import { FluidTabs } from "@/components/ui/fluid-tabs"

const tabs = [
  {
    value: "inbox",
    label: "Inbox",
    accentColor: "#087cf0",
    id: "inbox-tab",
    panelId: "inbox-panel",
    icon: (
      <svg aria-hidden="true" fill="none" viewBox="0 0 24 24">
        <path d="M3 6.5h18v12H3zM4 8l8 6 8-6" stroke="currentColor" strokeWidth="2" />
      </svg>
    ),
  },
  {
    value: "planner",
    label: "Planner",
    accentColor: "#f0b429",
    id: "planner-tab",
    panelId: "planner-panel",
    icon: (
      <svg aria-hidden="true" fill="none" viewBox="0 0 24 24">
        <path d="M4 5h16v15H4zM8 3v4m8-4v4M4 10h16" stroke="currentColor" strokeWidth="2" />
      </svg>
    ),
  },
  {
    value: "alerts",
    label: "Alerts",
    accentColor: "#f0443e",
    id: "alerts-tab",
    panelId: "alerts-panel",
    icon: (
      <svg aria-hidden="true" fill="currentColor" viewBox="0 0 24 24">
        <path d="M12 2a6 6 0 0 0-6 6v4l-2 4v2h16v-2l-2-4V8a6 6 0 0 0-6-6Zm-2 18h4a2 2 0 0 1-4 0Z" />
      </svg>
    ),
  },
]

export function WorkspaceTabs() {
  return (
    <FluidTabs
      aria-label="Workspace"
      defaultValue="inbox"
      tabs={tabs}
    />
  )
}
npm
// app/globals.css
@import "suluu/styles.css";
@source "../node_modules/suluu/dist";

// component.tsx
import { FluidTabs } from "suluu/fluid-tabs"

<FluidTabs
  aria-label="Workspace"
  defaultValue="inbox"
  tabs={tabs}
/>

Controlled tabs and panels

FluidTabs owns the triggers only. When it switches product content, connect each trigger and panel with matching ids and keep inactive panels hidden.

With panels
const [tab, setTab] = useState("inbox")

<>
  <FluidTabs
    aria-label="Workspace"
    onValueChange={setTab}
    tabs={tabs}
    value={tab}
  />

  {tabs.map((item) => (
    <section
      aria-labelledby={item.id}
      hidden={tab !== item.value}
      id={item.panelId}
      key={item.value}
      role="tabpanel"
    >
      {item.label} content
    </section>
  ))}
</>

Sizes

The tablist is built from a single em geometry, so a size is only a change of type scale. Override--suluu-fluid-tabs-font-sizeon the tablist to scale it continuously instead. The active pill also clamps itself to the viewport, so a long label narrows rather than pushing the row off a small screen.

Sizes
// Three type scales; every dimension is derived from them.
<FluidTabs size="sm" tabs={tabs} />
<FluidTabs tabs={tabs} />          // md, the default
<FluidTabs size="lg" tabs={tabs} />

// Or scale it continuously by setting the em basis yourself.
<FluidTabs
  style={{ "--suluu-fluid-tabs-font-size": "1.125rem" }}
  tabs={tabs}
/>

In context

A communication workspace uses the expanding active tab to preserve context without giving every destination a permanent label.

Workspace

Everything waiting for you today.

3 unread

The launch review is ready

Mara left two notes on the final interaction pass.

Props

FluidTabs accepts safe native div attributes and forwards its ref to the tablist. Each item may also set thedisabled state without disabling its siblings.

PropTypeDefaultDescription
tabsFluidTab[]Tabs rendered in visual and keyboard order.
tabs[].valuestringUnique value used for selection.
tabs[].labelstringVisible and accessible tab name.
tabs[].iconReactNodeCustom icon-sized content.
tabs[].accentColorstringTheme accentCSS color used while that tab is active.
tabs[].id / panelIdstringGenerated / —Optional ids connecting a trigger to its tabpanel.
valuestringControlled active value.
defaultValuestringFirst enabled tabInitial uncontrolled active value.
onValueChange(value) => voidRuns when an interaction requests another tab.
size"sm" | "md" | "lg""md"Type scale the whole control is sized from.
disabledbooleanfalseDisables the complete tablist.
classNamestringClass name applied to the tablist.

Theming

Override these variables in your light and dark theme scopes. The registry installs the defaults automatically.

--suluu-fluid-tabs-font-sizeEm basis every dimension is derived from.
--suluu-fluid-tabs-backgroundIndividual circle and pill surface.
--suluu-fluid-tabs-foregroundInactive icon color.
--suluu-fluid-tabs-borderSurface edge.
--suluu-fluid-tabs-accentFallback active icon and label color.
--suluu-fluid-tabs-ringKeyboard focus ring.
--suluu-fluid-tabs-offsetColor behind the focus ring offset.
--suluu-fluid-tabs-shadowCircle and pill elevation.
--suluu-fluid-tabs-shimmer-intensityStrength of the sheen that travels the revealed label as it opens.

The root exposes data-state for the active value. Triggers exposedata-state asactive orinactive. An item’saccentColor overrides the fallback accent for that trigger only.

Accessibility

FluidTabs is a horizontal tablist of native buttons. Left and Right arrows move and activate selection, Home and End jump to the first and last enabled tabs, and only the active tab sits in the page tab order.

The label remains the accessible name even while visually collapsed. With reduced motion, expansion, label movement, and color transitions become immediate while active state remains clear.

The sweep of light is decorative: it is hidden from assistive technology, never intercepts pointer events, and is not rendered at all under reduced motion. Selection stays legible through the accent color alone.

↑↓ to navigate↵ to openesc to close