reelcn
GitHubBack to landing

Tabs

Tab row where one continuous fractional index positions the sliding pill, the lit label color and a small per-row parallax. Nothing animates independently of that one number.

Tabs, defaults
Tabs, defaults thumbnail, 16:9

Installation

npx shadcn add https://www.reelcn.dev/r/tabs.json

Files land in src/reelcn/ with the items it builds on.

Usage

<Center>
  <Tabs id="view" labels={["Overview", "Usage", "Billing"]} steps={[{ at: 0, state: { active: 0 } }, { at: 1, state: { active: 2 } }]} />
</Center>

Props

PropTypeDescription
delaynumberFrames to wait before entering.
durationnumberEnter duration in frames. Defaults to 0.6s. Some items reuse duration for an unrelated local timing. See that item's own prop docs.
exitnumber | booleanExit at the end of the parent `<Sequence>`. false keeps it on screen, a number sets the exit length in frames.
motionMotionPersonalityOverride the theme's motion personality.
posterbooleanRenders fully entered, with no exit: for a still frame or thumbnail. Defaults to false.
holdFramesnumberDelays the start of the exit window by this many frames, for a minimum on-screen hold. Defaults to 0.
idstring
labels*string[]
stepsStep<{ active: number; }>[]
placePlace
styleCSSProperties
classNamestring

Built from

Use

  • Switching between views inside a UI-sim walkthrough
  • A `ui` scene's `component: "tabs"`

Avoid

  • Two or fewer static labels with no switch. Use plain text

Guides

Components take their colors from a theme, their timing from motion props and their layout from the canvas.

Source

registry/items/tabs.tsx
/**
 * @title Tabs
 * @category product
 * @description Tab row where one continuous fractional index positions the sliding pill, the lit label color and a small per-row parallax. Nothing animates independently of that one number.
 * @duration data-driven
 * @use Switching between views inside a UI-sim walkthrough
 * @use A `ui` scene's `component: "tabs"`
 * @avoid Two or fewer static labels with no switch. Use plain text
 * @tags tabs, segmented, ui, kit
 * @example
 * <Center>
 *   <Tabs id="view" labels={["Overview", "Usage", "Billing"]} steps={[{ at: 0, state: { active: 0 } }, { at: 1, state: { active: 2 } }]} />
 * </Center>
 */
import type React from "react";
import {
  type AnchorRect,
  anchorId,
  type MotionProps,
  measurePx,
  type Place,
  proximityWeight,
  type Step,
  useKeyframeState,
  useMotion,
  useTextMetrics,
  useTheme,
  useViewport,
} from "./core";

export type TabsProps = MotionProps & {
  id?: string;
  labels: string[];
  steps?: Step<{ active: number }>[];
  place?: Place;
  style?: React.CSSProperties;
  className?: string;
};

export const TAB_HEIGHT = 48;
export const PAD_X = 22;
export const FONT_SIZE = 18;
export const FONT_WEIGHT = 600;

/** `values[index]`, linearly interpolated between its two neighboring integer entries. */
function lerpAt(values: number[], index: number): number {
  const i = Math.max(0, Math.min(values.length - 1, Math.floor(index)));
  const j = Math.max(0, Math.min(values.length - 1, i + 1));
  return values[i] + (values[j] - values[i]) * (index - i);
}

/** Pure: given each label's already-measured raw width, every tab's padded width, its running offset
 * and the row's total width, shared by `useTabsAnchors`, `Tabs` and the `ui` scene (Task 9), so a
 * caller's `labels` are always the one thing sizing everything (ponytail-review blocker 2). */
export function tabsBoxSize(u: (n: number) => number, measuredLabelWidths: number[]) {
  const widths = measuredLabelWidths.map((w) => w + u(PAD_X) * 2);
  const offsets: number[] = [];
  let acc = 0;
  for (const w of widths) {
    offsets.push(acc);
    acc += w;
  }
  return { widths, offsets, total: acc, height: u(TAB_HEIGHT) };
}

/** Each label's own raw width, measured once font-ready is known. */
function useTabLayout(labels: string[], size: number, u: (n: number) => number, skip: boolean) {
  const theme = useTheme();
  const gate = useTextMetrics(
    labels.join(""),
    { fontFamily: theme.fonts.body, fontSize: u(size), fontWeight: FONT_WEIGHT },
    { skip },
  );
  const fontString = `${FONT_WEIGHT} ${u(size)}px ${theme.fonts.body}`;
  const measuredWidths = labels.map((l) => (gate.ready ? measurePx(l, fontString) : u(48)));
  return tabsBoxSize(u, measuredWidths);
}

export function useTabsAnchors(props: TabsProps): Record<string, AnchorRect> {
  const { u, width, height } = useViewport();
  const skip = !props.id || !props.place;
  const layout = useTabLayout(props.labels, FONT_SIZE, u, skip);
  if (!props.id || !props.place) return {};
  const hPct = (u(TAB_HEIGHT) / height) * 100;
  const leftPx = (props.place.x / 100) * width - layout.total / 2;
  const topPct = props.place.y - hPct / 2;
  const anchors: Record<string, AnchorRect> = {};
  props.labels.forEach((_, i) => {
    anchors[anchorId(props.id as string, i)] = {
      x: ((leftPx + layout.offsets[i]) / width) * 100,
      y: topPct,
      width: (layout.widths[i] / width) * 100,
      height: hPct,
    };
  });
  return anchors;
}

export function Tabs({ id: _id, labels, steps, place, style, className, ...motion }: TabsProps) {
  const theme = useTheme();
  const { u } = useViewport();
  const m = useMotion(motion);
  const layout = useTabLayout(labels, FONT_SIZE, u, false);
  const { from, state, progress } = useKeyframeState<{ active: number }>(steps, { active: 0 });
  // The one continuous number everything below reads off: the pill's x/width, every label's color, and
  // the per-row parallax are all a function of this same `index`, nothing animated independently (M1).
  const index = from.active + (state.active - from.active) * progress;
  const pillX = lerpAt(layout.offsets, index);
  const pillW = lerpAt(layout.widths, index);

  return (
    <div
      className={className}
      style={{
        position: place ? "absolute" : "relative",
        left: place ? `${place.x}%` : undefined,
        top: place ? `${place.y}%` : undefined,
        translate: place ? "-50% -50%" : undefined,
        display: "flex",
        height: u(TAB_HEIGHT),
        borderRadius: u(theme.radius),
        background: theme.colors.surface,
        opacity: m.presence,
        ...style,
      }}
    >
      <div
        style={{
          position: "absolute",
          left: pillX,
          top: 0,
          width: pillW,
          height: u(TAB_HEIGHT),
          borderRadius: u(theme.radius),
          background: theme.colors.accent,
        }}
      />
      {labels.map((label, i) => {
        const w = proximityWeight(i, index);
        return (
          <div
            key={label}
            style={{
              position: "relative",
              width: layout.widths[i],
              height: u(TAB_HEIGHT),
              display: "flex",
              alignItems: "center",
              justifyContent: "center",
              translate: `0 ${(1 - w) * u(2)}px`,
              color: `color-mix(in srgb, ${theme.colors.accentForeground} ${Math.round(w * 100)}%, ${theme.colors.muted})`,
              fontFamily: theme.fonts.body,
              fontSize: u(FONT_SIZE),
              fontWeight: FONT_WEIGHT,
            }}
          >
            {label}
          </div>
        );
      })}
    </div>
  );
}

On this page