gitlab-admin-ui
by @chempa
Use when building an admin dashboard, internal tool, or data-dense back office that should look like GitLab's admin area - dark, dense, technical, atomic-design components.
GitLab-style admin UI
A design system for dense, technical admin interfaces in the spirit of GitLab's admin area. Derived from software-consultancy/ui-gitlab, which is a working Next.js implementation of it.
Reach for this when the screen is a tool, not a marketing page: tables of records, status columns, filters, bulk actions, keyboard operators who live in the thing all day. Do not reach for it when the goal is a landing page or anything that wants whitespace and emotion.
The vibe, in five rules
Everything below follows from these. If a decision is ambiguous, re-read them.
- Dark, in exactly four surface tones. Near-black base, a slightly lighter
panel, a raised tone for menus and hover, an extra-raised tone for chips and badges. Add a fifth and the depth flattens into mush.
- Hairlines, not shadows. A 1px divider is the only separation between
panels. There is exactly one shadow in the entire UI, under the top bar. No gradients anywhere.
- Orange is punctuation, never prose. The brand orange appears on the logo,
the single most important button on a screen, the active row's left border, and focus rings. It never appears on body text or as a background fill.
- Dense. Rows are 26-36px tall, not 48-60. Text is 11-13px. This is a UI
that shows fifty rows, not eight.
- Sharp motion. 120-180ms,
cubic-bezier(0.2, 0, 0, 1). Technical tools
should feel instant, not springy.
Copy: no em-dashes
Never use an em-dash (U+2014, the long dash) in any user-facing string: labels, table headers, empty states, toasts, tooltips, confirmation dialogs, error messages, the shortcut overlay.
It is the loudest tell that text was generated. Models reach for it constantly and real product UI almost never does. In an admin tool it is worse than on a marketing page, because operator-facing copy should be terse and an em-dash is the punctuation of a sentence that would not commit.
Written below as [EM] because this file, like the copy it describes, does not contain one:
bad Deleted 3 files [EM] this cannot be undone.
good Deleted 3 files. This cannot be undone.
bad No pipelines yet [EM] runs will appear here.
good No pipelines yet. Runs will appear here.
bad Status [EM] failed
good Status: failed
A full stop or a colon is almost always the better edit. Keep en-dashes only in numeric ranges (26-36px), where a hyphen reads fine anyway.
Start here
Copy the token block first - it is the whole design system, and every component below just spends these variables:
references/tokens.css -> your src/app/globals.css
Read references/tokens.css before writing any component. It is annotated with why each value is what it is.
Then build upward through the atomic layers, in order. Do not start with a page.
Atomic layering
Components live in four folders, and the rule for which folder is about dependencies, not size:
| Layer | Rule | Examples |
|---|---|---|
atoms/ | Spends tokens. Imports no other component. | Button, Badge, Input, Kbd, Avatar, StatusDot |
molecules/ | Composes atoms. Owns no page state. | PageHeader, StatCard, FilterChip, ResourceTable, Breadcrumbs, EmptyState |
organisms/ | Owns real state or a portal. Composes molecules. | Sidebar, TopBar, CommandPalette, Toast, StatusBar, ShortcutOverlay |
templates/ | Arranges organisms into a shell. One per app. | AdminShell |
Pages import a template and molecules. A page should almost never import an atom directly - if it wants to, the molecule you actually needed is missing.
The payoff is replaceability: swapping the entire look means rewriting atoms and the token file, and nothing above them changes.
See references/components.md for the component conventions - the variant-map pattern, when to use forwardRef, how cn() resolves Tailwind conflicts.
The shell
Every route renders inside one template that owns the chrome: top bar, sidebar, status bar, command palette, shortcut overlay, toasts. Pages supply only a breadcrumb and their content area.
┌─ TopBar 48px ──────────────── logo · breadcrumb ····· ⌘K · bell · avatar ─┐
├──────────┬───────────────────────────────────────────────┬───────────────┤
│ Sidebar │ content area (the only scrolling region) │ Inspector │
│ 240px │ │ 320px │
│ (56px │ │ (optional) │
│ collapsed) │ │
├──────────┴───────────────────────────────────────────────┴───────────────┤
└─ StatusBar 28px ── branch · sync · selection · "Press ? for shortcuts" ───┘
body { overflow: hidden } - the shell manages its own scrolling. Getting this wrong produces a double scrollbar, which is the fastest way to make an admin tool feel amateur.
references/shell.md has the anatomy, the keyboard map, and the interaction inventory (palette, context menu, drag-and-drop, toasts).
Theming
Tokens are CSS custom properties inside Tailwind v4's @theme. A light theme is an override block on html.light that redefines the same variable names:
@theme { --color-surface-0: #1a1d23; } /* dark default */
html.light { --color-surface-0: #ffffff; } /* light override */
Utilities re-resolve through the variables automatically, so no component class ever changes between themes. If you find yourself writing dark:bg-… light:bg-… in a component, the token is missing.
Note the brand orange is deliberately identical in both modes. GitLab uses one orange; a "light mode orange" would read as a different product.
Maintainability
The theming rule is the maintainability rule. "No component class ever changes between themes" is not a tidiness preference. It means the entire look lives in one file, so a rebrand, a light mode, or a client's palette is an override block rather than a sweep through every component. The moment a component contains dark:bg-… light:bg-…, that property has escaped the token file and will have to be found by hand later. Treat a missing token as the bug, not the workaround.
Atoms are the replaceability boundary. The payoff named above, that swapping the look means rewriting atoms and the token file and nothing above them, only holds while molecules and organisms spend atoms rather than raw utilities. A page reaching past a molecule to style an atom directly has quietly welded the two layers together, which is why "a page should almost never import an atom" is worth enforcing rather than suggesting.
Component inventory grows and never shrinks. An admin tool accretes: a fourth badge variant, a second table, a one-off panel for one screen. Each is cheap to add and permanent, because nothing ever prompts you to remove it. Two habits keep it survivable:
- **Before adding a variant, check whether an existing one is being avoided for
a reason worth fixing.** Usually the answer is a missing prop, not a new component.
- Run a periodic unimported-file pass (
knip,ts-prune) and actually
delete what it finds. In a component library this is unusually accurate, because components are imported explicitly or not at all.
Forked per project, so fixes need a direction. Like its ui-apple sibling, this is cloned rather than installed. Land system-level fixes upstream and pull them forward, and keep a note of which starter commit a project came from. Otherwise the twelfth project is running the first project's bugs.
See the maintainability skill for the general treatment.
Stack
Next.js App Router, Tailwind v4, clsx + tailwind-merge (as cn()), lucide-react for icons, Inter + JetBrains Mono via next/font. Mono is for paths, SHAs, sizes, counts, and timestamps - anything a person might compare character by character.
No component library. The atoms are the library, and they are small enough to read in an afternoon.
Source
git@gitlab.com:software-consultancy/ui-gitlab.git is the reference implementation. Its src/app/ has since grown into a real product, so read src/components/ and src/app/globals.css for the system and ignore the domain routes.
For a different aesthetic entirely, that repo's sibling is ui-apple - one design per repo, deliberately.