>_devkit
gitlab-admin-ui
skills/gitlab-admin-ui/

references/components.md

Component conventions

Read after SKILL.md. These are the patterns every component in the system follows - copying them is what keeps thirty components feeling like one.

The variant-map pattern

Variants are a Record keyed by a union type, not a chain of conditionals. The type and the map are declared together, so adding a variant without styling it is a compile error.

import { ButtonHTMLAttributes, forwardRef, ReactNode } from "react";
import { cn } from "@/lib/cn";

type Variant = "primary" | "secondary" | "ghost" | "danger";
type Size = "sm" | "md";

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: Variant;
  size?: Size;
  /** Optional leading icon (lucide-react component instance). */
  icon?: ReactNode;
}

const VARIANTS: Record<Variant, string> = {
  primary:   "bg-brand text-white hover:bg-brand-hover focus-visible:ring-brand-ring",
  secondary: "bg-surface-2 text-ink-primary border border-divider hover:border-divider-2 hover:bg-surface-3",
  ghost:     "bg-transparent text-ink-secondary hover:bg-surface-2 hover:text-ink-primary",
  danger:    "bg-transparent text-danger hover:bg-danger-soft",
};

const SIZES: Record<Size, string> = {
  sm: "h-7 px-2.5 text-[12px] gap-1.5",
  md: "h-8 px-3 text-[13px] gap-2",
};

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
  { variant = "secondary", size = "md", icon, className, type = "button", children, ...rest },
  ref
) {
  return (
    <button
      ref={ref}
      type={type}
      className={cn(
        "inline-flex items-center justify-center font-medium rounded-md press-feedback",
        "transition-colors focus:outline-none",
        "disabled:opacity-40 disabled:cursor-not-allowed",
        VARIANTS[variant],
        SIZES[size],
        className
      )}
      {...rest}
    >
      {icon && <span className="shrink-0">{icon}</span>}
      {children}
    </button>
  );
});

Five things in there are load-bearing:

  • secondary is the default, not primary. Orange is punctuation. If every

button defaulted to primary you would end up with six oranges on a screen.

  • className comes last in cn(), so a caller can always override.
  • The rest props are spread, so a consumer gets onClick, aria-*,

data-* without the component enumerating them.

  • forwardRef on anything focusable or measurable - popovers, palettes, and

focus management all need the node.

  • type="button" by default. The HTML default is submit, which silently

submits a surrounding form. This has bitten every codebase that forgot it.

cn()

import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]): string {
  return twMerge(clsx(inputs));
}

clsx handles conditionals; twMerge resolves Tailwind conflicts so a later class actually wins. Without twMerge, cn("px-3", "px-6") emits both and the result depends on stylesheet order - the bug where an override "randomly" works on one component and not another.

Badge

Status pills are mono, 10px, uppercase, tracked, with a soft-tinted background from the accent tokens. The -soft variables exist exactly for this.

const VARIANTS: Record<Variant, string> = {
  default: "bg-surface-3 text-ink-secondary",
  success: "bg-success-soft text-success",
  danger:  "bg-danger-soft text-danger",
  warning: "bg-warning-soft text-warning",
  info:    "bg-info-soft text-info",
  brand:   "bg-brand-soft text-brand",
};

// inline-flex items-center px-1.5 h-[18px] rounded-sm whitespace-nowrap
// font-mono text-[10px] uppercase tracking-wider font-medium

An 18px fixed height matters: badges live inside 26-36px rows, and letting them size to content makes rows jitter between values.

Layer inventory

What a mature version of this system contains, as a target to build toward:

atoms - Avatar, Badge, Button, IconButton, Input, Kbd, LogoTile, StatusDot

molecules - Breadcrumbs, EmptyState, ErrorBanner, FilterChip, PageHeader, ResourceTable, SidebarItem, StatCard, TableLink, plus domain pickers (UserPicker, AdminPicker) and editors (KeyValueRowsEditor, SpecListEditor)

organisms - CommandPalette, ShortcutOverlay, Sidebar, StatusBar, Toast, TopBar

templates - AdminShell

Note the shape: few atoms, many molecules. That ratio is healthy. A system with thirty atoms has usually mislabelled its molecules, and a system with three molecules is about to grow copy-pasted table markup on every page.

Utilities worth having

From globals.css, referenced by components:

  • .press-feedback - scale(0.98) on :active. Every interactive element.
  • .scrollbar-thin - themed scrollbars; the default light scrollbar on a dark

panel is jarring.

  • .shadow-topbar - the one shadow in the UI.
  • A global *:focus-visible orange ring, explicitly disabled on

input/textarea/select, because the Input atom already changes its wrapper border on focus-within and two rings read as a rendering bug.

  • A prefers-reduced-motion block that flattens all durations.