Skip to content
goodtabs UI
BrowseBrowse components

Tab field

Forty drifting browser tabs that settle into a calm grid of ten as progress rises.

npx shadcn@latest add https://ui.goodtabs.co/r/tab-field.json

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

A ScrollTrigger scrub drives progress: 40 tabs drift, 30 fall away, 10 land with an indigo mark.

Keep six

A button tweens a ref from 0 to 1, with keep at 6 for a grid of 3 by 2.

Usage

import { TabField } from "@/components/ui/tab-field"

const progress = React.useRef(0)
// gsap.to(progress, { current: 1, scrollTrigger: { scrub: true } })

<TabField progress={progress} className="h-dvh pt-40" />

Props

PropTypeDefaultDescription
progressnumber | { current: number }00 is the drifting field, 1 the finished grid. A ref is read every frame with no re-render.
keepnumber10How many of the 40 tabs stay. Half as many columns as tabs, or two columns in a narrow box.
speednumber1Multiplies the drift.
maxDprnumber2Upper limit on device pixels per CSS pixel.
settleAfternumberSeconds until the drift eases to rest. Set it at 5 or less where nothing else can pause the motion.

Source

tab-field/tab-field.tsx
"use client";

import * as React from "react";

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

/** A plain number, or anything holding one in `current`, such as a ref a tween mutates every frame. */
type TabFieldProgress = number | { current: number };

type TabFieldProps = React.ComponentProps<"div"> & {
  /** 0 is the drifting field, 1 the finished grid. A ref is read every frame, so a tween never re-renders. */
  progress?: TabFieldProgress;
  /** How many of the 40 tabs stay. 10 gives a grid of 5 by 2, or 2 by 5 in a narrow box. */
  keep?: number;
  /** Multiplies the drift. */
  speed?: number;
  /** Seconds until the drift comes to rest. Motion that starts by itself and lasts over 5s needs a pause control. */
  settleAfter?: number;
  /** Upper limit on device pixels per CSS pixel. */
  maxDpr?: number;
};

const TOTAL = 40;
const STRIDE = 11;
/** Full height over full width of one tab: a real browser tab is about three to one. */
const RATIO = 1 / 3;
const MAX_WIDTH = 280;
const NARROW = 640;

const VERTEX = `attribute vec2 aCorner;
attribute vec2 aHome;
attribute vec2 aSlot;
attribute vec4 aSeed;
attribute float aKept;
uniform float uTime;
uniform float uProgress;
uniform float uFull;
uniform float uLoose;
uniform vec2 uResolution;
uniform vec2 uCenter;
uniform vec2 uPitch;
varying vec2 vLocal;
varying float vPixel;
varying float vAlpha;
varying float vSettled;
varying float vTitle;

// Quintic ease in and out, flat at both ends, so a tab leaves and lands with no visible start or stop.
float settle(float t) {
  t = clamp(t, 0.0, 1.0);
  return t * t * t * (t * (t * 6.0 - 15.0) + 10.0);
}

void main() {
  float depth = aSeed.x;
  float phase = aSeed.y * 6.2832;
  float lag = aSeed.w;

  // Sines at unrelated rates, so the drift never visibly repeats and never needs wrapping.
  vec2 wander = vec2(
    sin(uTime * 0.13 + phase) + 0.5 * sin(uTime * 0.07 + phase * 2.3),
    cos(uTime * 0.11 + phase * 1.4) + 0.5 * cos(uTime * 0.05 + phase * 3.1)
  );
  vec2 loose = aHome * uResolution + wander * uLoose * mix(0.4, 1.0, depth);
  float looseSize = uLoose * mix(0.72, 1.25, depth);
  float turn = (aSeed.z - 0.5) * 0.5 + 0.08 * sin(uTime * 0.09 + phase);
  float fog = mix(0.62, 1.0, depth);

  // Every kept tab lands exactly at progress 1: 0.08 + 0.22 + 0.7.
  float arrive = settle((uProgress - 0.08 - lag * 0.22) / 0.7);
  vec2 keptCenter = mix(loose, uCenter + aSlot * uPitch, arrive);
  float keptSize = mix(looseSize, uFull, arrive);
  float keptTurn = turn * (1.0 - arrive);
  float keptAlpha = mix(fog, 1.0, arrive);

  // The rest go first, falling on a squared curve, nearer ones faster.
  float drop = clamp((uProgress - lag * 0.3) / 0.55, 0.0, 1.0);
  vec2 fallCenter = loose + vec2(0.0, uResolution.y * mix(0.6, 1.1, depth) * drop * drop);
  float fallSize = looseSize * (1.0 - 0.2 * drop);
  float fallTurn = turn + (aSeed.z - 0.5) * 0.9 * drop;
  float fallAlpha = fog * (1.0 - smoothstep(0.05, 0.8, drop));

  vec2 center = mix(fallCenter, keptCenter, aKept);
  float size = mix(fallSize, keptSize, aKept);
  float angle = mix(fallTurn, keptTurn, aKept);
  vAlpha = mix(fallAlpha, keptAlpha, aKept);
  vSettled = arrive * aKept;
  vTitle = aSeed.y;

  // Shape space has a half width of 1. Two spare device pixels keep the soft edge inside the quad.
  vec2 reach = vec2(1.0, ${RATIO.toFixed(4)}) * size + 2.0;
  vec2 offset = aCorner * reach;
  vLocal = offset / size;
  vPixel = 1.0 / size;

  float c = cos(angle);
  float s = sin(angle);
  offset = vec2(c * offset.x - s * offset.y, s * offset.x + c * offset.y);
  vec2 clip = (center + vec2(offset.x, -offset.y)) / uResolution * 2.0 - 1.0;
  gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0);
}`;

const FRAGMENT = `precision mediump float;
uniform vec3 uInk;
uniform vec3 uSurface;
uniform vec3 uMark;
uniform float uScale;
varying vec2 vLocal;
varying float vPixel;
varying float vAlpha;
varying float vSettled;
varying float vTitle;

const float HEIGHT = ${RATIO.toFixed(4)};
const float FOOT = 0.12;
const float WAIST = 1.0 - FOOT;
const float CROWN = 0.14;

float rounded(vec2 p, vec2 size, float radius) {
  vec2 q = abs(p) - size + radius;
  return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - radius;
}

// The brand tab: a rounded top, a flat base, and two feet that flare out into the baseline.
float tab(vec2 p) {
  // The box runs past the baseline, so only its top corners round, and the baseline cuts it flat.
  float body = max(rounded(p + vec2(0.0, CROWN), vec2(WAIST, HEIGHT + CROWN), CROWN), -HEIGHT - p.y);

  // A foot is a square with a circle taken out of it. The bite is the concave flare, tangent to the
  // side of the tab and to the baseline. The square reaches into the body so no seam shows.
  vec2 q = vec2(abs(p.x), p.y);
  vec2 square = q - vec2(WAIST + FOOT * 0.5 - 0.05, -HEIGHT + FOOT * 0.5);
  float box = rounded(square, vec2(FOOT * 0.5 + 0.05, FOOT * 0.5), 0.0);
  float foot = max(box, FOOT - length(q - vec2(1.0, FOOT - HEIGHT)));
  return min(body, foot);
}

float dotAt(vec2 p, vec2 center, float radius) {
  return 1.0 - smoothstep(-vPixel, vPixel, length(p - center) - radius);
}

void main() {
  vec2 p = vLocal;
  float d = tab(p);

  float inside = 1.0 - smoothstep(-vPixel, vPixel, d);
  // One CSS pixel wide at every size, with one device pixel of soft edge.
  float hair = 0.5 * uScale * vPixel;
  float line = 1.0 - smoothstep(hair - vPixel * 0.5, hair + vPixel * 0.5, abs(d));

  // One light from above: the crown catches it, the base falls away.
  float lit = smoothstep(-HEIGHT, HEIGHT, p.y);
  float lineAlpha = line * mix(0.3, 0.8, lit);
  float fillAlpha = inside * 0.94;
  // A tint of ink lifts the tab off the page in both themes. A kept tab steps up, like the active one.
  vec3 surface = mix(uSurface, uInk, mix(0.085, 0.12, vSettled) + 0.03 * lit);

  // One favicon and one title in every tab, with titles of different lengths.
  float reach = mix(0.24, 0.42, vTitle);
  float icon = dotAt(p, vec2(-0.6, 0.0), 0.078);
  float title = 1.0 - smoothstep(-vPixel, vPixel, rounded(p - vec2(-0.42 + reach, 0.0), vec2(reach, 0.03), 0.03));
  float detail = max(icon * mix(0.6, 0.8, vSettled), title * mix(0.3, 0.42, vSettled)) * inside;

  vec3 color = surface * fillAlpha * (1.0 - lineAlpha) + uInk * lineAlpha;
  float alpha = lineAlpha + fillAlpha * (1.0 - lineAlpha);
  color = mix(color, uInk * alpha, detail);

  // Indigo says kept: a small mark that only a tab someone chose ever shows.
  float chosen = dotAt(p, vec2(0.66, 0.0), 0.04) * smoothstep(0.75, 1.0, vSettled);
  color = mix(color, uMark * alpha, chosen);
  gl_FragColor = vec4(color, alpha) * vAlpha;
}`;

const TOKENS = { uInk: "--foreground", uSurface: "--card", uMark: "--brand" };

/** Resolves a token through the cascade, so a scoped .light or .dark wrapper is respected. */
function readToken(host: HTMLElement, token: string): [number, number, number] {
  const probe = document.createElement("span");
  probe.style.color = `var(${token})`;
  probe.style.display = "none";
  host.appendChild(probe);
  const color = getComputedStyle(probe).color;
  probe.remove();
  const paint = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
  if (!paint) return [0, 0, 0];
  paint.fillStyle = color;
  paint.fillRect(0, 0, 1, 1);
  const [r, g, b] = paint.getImageData(0, 0, 1, 1).data;
  return [r / 255, g / 255, b / 255];
}

function compile(gl: WebGLRenderingContext, type: number, source: string) {
  const shader = gl.createShader(type);
  if (!shader) return null;
  gl.shaderSource(shader, source);
  gl.compileShader(shader);
  if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
  console.error(gl.getShaderInfoLog(shader));
  gl.deleteShader(shader);
  return null;
}

const fract = (n: number) => n - Math.floor(n);

/** Two triangles per tab in one buffer, so 40 tabs are one draw call and need no extension. */
function buildField(keep: number, columns: number) {
  const tabs = Array.from({ length: TOTAL }, (_, i) => ({
    // The R2 sequence scatters any prefix evenly, so the first `keep` tabs are spread over the field.
    home: [fract(0.5 + i * 0.7548776662) * 0.94 + 0.03, fract(0.5 + i * 0.569840291) * 0.94 + 0.03],
    depth: fract(0.31 + i * 0.6180339887),
    phase: fract(0.17 + i * 0.7320508076),
    tilt: fract(0.43 + i * 0.4142135624),
    lag: fract(0.61 + i * 0.2360679775),
    slot: [0, 0],
    kept: i < keep ? 1 : 0,
  }));

  // Rows by height, then each row by x, so the tabs that stay do not cross on their way in.
  const kept = tabs.filter((tab) => tab.kept).sort((a, b) => a.home[1] - b.home[1]);
  const rows = Math.ceil(keep / columns);
  for (let row = 0; row < rows; row += 1) {
    const line = kept.slice(row * columns, (row + 1) * columns).sort((a, b) => a.home[0] - b.home[0]);
    line.forEach((tab, column) => {
      tab.slot = [column - (line.length - 1) / 2, row - (rows - 1) / 2];
      // They land in reading order.
      tab.lag = (row * columns + column) / Math.max(keep - 1, 1);
    });
  }

  // No depth buffer: far tabs paint first, and the tabs that stay paint over everything.
  tabs.sort((a, b) => a.kept - b.kept || a.depth - b.depth);

  const corners = [-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1];
  const data = new Float32Array(TOTAL * 6 * STRIDE);
  tabs.forEach((tab, t) => {
    for (let v = 0; v < 6; v += 1) {
      data.set(
        [
          corners[v * 2],
          corners[v * 2 + 1],
          tab.home[0],
          tab.home[1],
          tab.slot[0],
          tab.slot[1],
          tab.depth,
          tab.phase,
          tab.tilt,
          tab.lag,
          tab.kept,
        ],
        (t * 6 + v) * STRIDE,
      );
    }
  });
  return data;
}

/** The finished grid in CSS, shown without WebGL and with no script at all. */
function TabFieldStill({ keep }: { keep: number }) {
  return (
    <div
      style={{ "--tab-field-columns": Math.ceil(keep / 2) } as React.CSSProperties}
      className="mx-auto grid size-full max-w-[1464px] grid-cols-2 content-center gap-3 motion-safe:animate-in motion-safe:delay-500 motion-safe:fill-mode-both motion-safe:fade-in sm:grid-cols-[repeat(var(--tab-field-columns),minmax(0,1fr))] sm:gap-4"
    >
      {Array.from({ length: keep }, (_, i) => (
        <svg key={i} viewBox="0 0 120 40" className="aspect-3/1 w-full overflow-visible">
          <path
            d="M0 40c4.8 0 7.2-2.4 7.2-7.2V12C7.2 5.4 12.6 0 19.2 0h81.6c6.6 0 12 5.4 12 12v20.8c0 4.8 2.4 7.2 7.2 7.2Z"
            vectorEffect="non-scaling-stroke"
            className="fill-card stroke-border"
          />
          <circle cx="24" cy="20" r="4.7" className="fill-muted-foreground" />
          <rect x="34.8" y="18.2" width="48" height="3.6" rx="1.8" className="fill-muted-foreground/50" />
          <circle cx="99.6" cy="20" r="2.4" className="fill-brand" />
        </svg>
      ))}
    </div>
  );
}

/**
 * Forty small browser tabs drift in a field, drawn in raw WebGL. Raising progress pulls `keep` of them
 * into a calm grid while the rest fall away and fade. The grid sits inside the element's padding, so pad
 * it to leave room for a heading. Under reduced motion, and without WebGL, the finished grid shows.
 */
function TabField({
  progress = 0,
  keep = 10,
  speed = 1,
  settleAfter,
  maxDpr = 2,
  className,
  children,
  ...props
}: TabFieldProps) {
  const root = React.useRef<HTMLDivElement>(null);
  const canvas = React.useRef<HTMLCanvasElement>(null);
  const latest = React.useRef({ progress, speed, settleAfter });
  const redraw = React.useRef<() => void>(() => {});
  const [ready, setReady] = React.useState(false);
  const count = Math.max(1, Math.min(TOTAL, Math.round(keep)));

  React.useEffect(() => {
    latest.current = { progress, speed, settleAfter };
    redraw.current();
  });

  React.useEffect(() => {
    const host = root.current;
    const el = canvas.current;
    if (!host || !el) return;
    // One still frame has to outlive the next composite, so reduced motion keeps the buffer.
    const gl = el.getContext("webgl", {
      preserveDrawingBuffer: window.matchMedia("(prefers-reduced-motion: reduce)").matches,
      alpha: true,
      antialias: false,
      depth: false,
      stencil: false,
      premultipliedAlpha: true,
      powerPreference: "low-power",
    });
    if (!gl) return;

    const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX);
    const frag = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT);
    const program = gl.createProgram();
    if (!vertex || !frag || !program) return;
    gl.attachShader(program, vertex);
    gl.attachShader(program, frag);
    gl.linkProgram(program);
    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
      console.error(gl.getProgramInfoLog(program));
      return;
    }
    gl.useProgram(program);

    const buffer = gl.createBuffer();
    gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
    const attributes: [string, number, number][] = [
      ["aCorner", 2, 0],
      ["aHome", 2, 2],
      ["aSlot", 2, 4],
      ["aSeed", 4, 6],
      ["aKept", 1, 10],
    ];
    for (const [name, size, offset] of attributes) {
      const location = gl.getAttribLocation(program, name);
      gl.enableVertexAttribArray(location);
      gl.vertexAttribPointer(location, size, gl.FLOAT, false, STRIDE * 4, offset * 4);
    }
    gl.enable(gl.BLEND);
    gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);

    const at = (name: string) => gl.getUniformLocation(program, name);
    const uniforms = {
      time: at("uTime"),
      progress: at("uProgress"),
      scale: at("uScale"),
      full: at("uFull"),
      loose: at("uLoose"),
      resolution: at("uResolution"),
      center: at("uCenter"),
      pitch: at("uPitch"),
    };

    const readTheme = () => {
      for (const [name, token] of Object.entries(TOKENS)) {
        gl.uniform3f(at(name), ...readToken(host, token));
      }
    };
    readTheme();

    const motion = window.matchMedia("(prefers-reduced-motion: reduce)");
    const stillAt = 2;
    let time = stillAt;
    let elapsed = 0;
    let visible = false;
    let frame = 0;
    let last = 0;
    let shown = -1;
    let columns = 0;

    const readProgress = () => {
      if (motion.matches) return 1;
      const value = latest.current.progress;
      return Math.max(0, Math.min(1, typeof value === "number" ? value : value.current));
    };

    const draw = () => {
      shown = readProgress();
      gl.viewport(0, 0, el.width, el.height);
      gl.clearColor(0, 0, 0, 0);
      gl.clear(gl.COLOR_BUFFER_BIT);
      gl.uniform1f(uniforms.time, time);
      gl.uniform1f(uniforms.progress, shown);
      gl.drawArrays(gl.TRIANGLES, 0, TOTAL * 6);
    };
    redraw.current = () => {
      if (motion.matches || readProgress() !== shown) draw();
    };

    const loop = (now: number) => {
      frame = 0;
      const dt = last ? Math.min((now - last) / 1000, 0.05) : 0;
      last = now;
      elapsed += dt;
      // Speed decays as e^(-3t / settleAfter), down to 5 percent at settleAfter, so it eases to rest.
      const rest = latest.current.settleAfter;
      const settled = rest !== undefined && elapsed >= rest;
      if (!settled) time += dt * latest.current.speed * (rest === undefined ? 1 : Math.exp((-3 * elapsed) / rest));
      // A finished grid or a settled field does not move, so it costs nothing until progress changes.
      const idle = (shown === 1 || settled) && readProgress() === shown;
      if (!idle) draw();
      schedule();
    };
    const schedule = () => {
      if (frame || !visible || motion.matches || document.hidden) return;
      frame = requestAnimationFrame(loop);
    };
    const stop = () => {
      if (frame) cancelAnimationFrame(frame);
      frame = 0;
      last = 0;
    };

    const resize = () => {
      const scale = Math.min(window.devicePixelRatio || 1, maxDpr);
      const width = Math.max(1, Math.round(el.clientWidth * scale));
      const height = Math.max(1, Math.round(el.clientHeight * scale));
      if (el.width !== width || el.height !== height) {
        el.width = width;
        el.height = height;
      }

      // The grid takes the element's content box, so responsive padding classes move it.
      const style = getComputedStyle(host);
      const left = parseFloat(style.paddingLeft) || 0;
      const top = parseFloat(style.paddingTop) || 0;
      const innerWidth = Math.max(1, el.clientWidth - left - (parseFloat(style.paddingRight) || 0));
      const innerHeight = Math.max(1, el.clientHeight - top - (parseFloat(style.paddingBottom) || 0));
      const narrow = innerWidth < NARROW;
      const nextColumns = Math.min(count, narrow ? 2 : Math.ceil(count / 2));
      if (nextColumns !== columns) {
        columns = nextColumns;
        gl.bufferData(gl.ARRAY_BUFFER, buildField(count, columns), gl.STATIC_DRAW);
      }
      const rows = Math.ceil(count / columns);
      const gap = narrow ? 12 : 16;
      const tabWidth = Math.min(
        (innerWidth - (columns - 1) * gap) / columns,
        (innerHeight - (rows - 1) * gap) / rows / RATIO,
        MAX_WIDTH,
      );
      // Forty loose tabs cover about a quarter of the field, whatever its size, and stay smaller than the grid.
      const covered = Math.sqrt((0.24 * el.clientWidth * el.clientHeight) / TOTAL / RATIO) / 2;
      gl.uniform1f(uniforms.scale, scale);
      gl.uniform1f(uniforms.full, (tabWidth / 2) * scale);
      gl.uniform1f(uniforms.loose, Math.min(covered, tabWidth * 0.3) * scale);
      gl.uniform2f(uniforms.resolution, width, height);
      gl.uniform2f(uniforms.center, (left + innerWidth / 2) * scale, (top + innerHeight / 2) * scale);
      gl.uniform2f(uniforms.pitch, (tabWidth + gap) * scale, (tabWidth * RATIO + gap) * scale);
      draw();
    };
    const resizer = new ResizeObserver(resize);
    resizer.observe(el);

    const viewport = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      if (visible) schedule();
      else stop();
    });
    viewport.observe(el);

    // Smooth scrolling libraries flip classes on html while scrolling, so react only to the theme itself.
    const themeKey = () => {
      const html = document.documentElement;
      return `${html.classList.contains("dark")} ${html.classList.contains("light")} ${html.style.colorScheme}`;
    };
    let lastTheme = themeKey();
    const theme = new MutationObserver(() => {
      if (themeKey() === lastTheme) return;
      lastTheme = themeKey();
      readTheme();
      draw();
    });
    theme.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "style"] });

    const onVisibility = () => (document.hidden ? stop() : schedule());
    const onMotion = () => {
      stop();
      if (motion.matches) {
        time = stillAt;
        draw();
      } else schedule();
    };
    document.addEventListener("visibilitychange", onVisibility);
    motion.addEventListener("change", onMotion);

    const onLost = (e: Event) => {
      e.preventDefault();
      stop();
      setReady(false);
    };
    el.addEventListener("webglcontextlost", onLost);

    resize();
    setReady(true);
    return () => {
      stop();
      redraw.current = () => {};
      resizer.disconnect();
      viewport.disconnect();
      theme.disconnect();
      document.removeEventListener("visibilitychange", onVisibility);
      motion.removeEventListener("change", onMotion);
      el.removeEventListener("webglcontextlost", onLost);
      gl.deleteBuffer(buffer);
      gl.deleteProgram(program);
      gl.deleteShader(vertex);
      gl.deleteShader(frag);
    };
  }, [count, maxDpr]);

  return (
    <div
      ref={root}
      data-slot="tab-field"
      className={cn("relative isolate overflow-hidden bg-background p-6 md:p-12", className)}
      {...props}
    >
      <div aria-hidden="true" className="absolute inset-0 -z-10 p-[inherit]">
        {ready ? null : <TabFieldStill keep={count} />}
        <canvas ref={canvas} className="absolute inset-0 size-full" />
      </div>
      {children}
    </div>
  );
}

export { TabField };
export type { TabFieldProgress, TabFieldProps };

For AI agents

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

When to use

  • A hero that tells the story of too many tabs becoming the few that matter
  • A scroll section where progress follows a scrub and ends on a calm grid
  • A background with one action that resolves it, such as a tidy up button

When not to use

  • Behind long text: the loose tabs cross the whole field
  • For real content: the tabs are drawn, so nothing in them can be read or clicked
  • More than once or twice on a page: browsers cap live WebGL contexts near 16

Agent notes

  • Pass a ref as progress and tween its current value. Passing a number re-renders the parent on every change
  • The element needs a height. The grid sits inside its padding, so use padding classes to leave room for a heading
  • Under reduced motion it always draws the finished grid, whatever progress says, so never rely on progress 0 being visible
  • The component imports no GSAP and declares none. Only the demos do, so copying them means installing gsap and @gsap/react from npm under GSAP's own licence
  • It has its own small WebGL setup rather than ShaderCanvas, because it draws 40 quads, and keeps the same behaviour: aria-hidden canvas, pauses off screen, follows the theme, CSS grid fallback

The maths

  • Forty tabs are 40 × 6 = 240 vertices in one buffer and one draw call, so a pixel only runs the signed distance for the tab that covers it.
  • Each foot is a square minus a circle, max(square, r - |p - c|), tangent to the side and the baseline, which is the concave flare of the logo.
  • Home positions follow the R2 sequence, frac(0.5 + n × 0.7549) and frac(0.5 + n × 0.5698), so the first ten tabs are spread evenly over the field.
  • A loose tab's half width is √(0.24 × area / 40 × 3) / 2 for a 3:1 tab, so forty tabs cover a quarter of the box at any size.