EmpireUI
Get Pro
← Blog8 min read#linear#dark ui#design

Linear-Inspired Dark UI: Focus Mode, Command Palette, Dense Layout

Build the dense, focused dark UI that Linear made famous — command palette, keyboard-first navigation, and tight spacing that actually ships in React.

Dark minimal SaaS dashboard with dense typography and keyboard navigation

What Linear Actually Got Right

Linear launched in 2020 and immediately made every other project management tool look embarrassed. Not because of features — it's a project tracker, not a spaceship. It's because the UI felt like it was built for people who actually live in their tools eight hours a day. Dense. Fast. Keyboard-first. No empty states with cartoon characters telling you to "Add your first item!"

The core insight was obvious in hindsight: most SaaS products treat white space as a proxy for polish. Linear said no. Information density is a feature when the density is intentional. You can see 40 issues on screen at once without squinting because the 32px row height, 13px type, and subdued icon colors work together instead of fighting each other.

Honestly, the thing that separates Linear's aesthetic from just "dark mode with a list" is restraint. One accent color — a cool indigo-purple. Borders at 1px, rgba(255,255,255,0.07). No gradients on interactive elements. No box shadows trying to add depth where there isn't any. Every visual decision is a deduction, not an addition.

If you want to replicate this in your own SaaS, you need to understand that it's not a theme — it's a philosophy. And that philosophy has three load-bearing pillars: focus mode, the command palette, and dense layout. We'll build all three.

Setting the Foundation: Dark Tokens That Don't Fight You

Before you write a single component, get your color tokens right. Linear uses a very specific dark: not pure #000000, not the fashionable #0f0f0f. The main surface is around #0f0e17 with a slight cool tint. Secondary surfaces step up to #1a1925. Borders live at rgba(255,255,255,0.08). Get these wrong and the whole thing feels cheap.

Set these as CSS custom properties so your components inherit them correctly. Tailwind v4 (released in early 2025) makes this trivial with @theme blocks, but plain CSS variables work fine too:

:root {
  --bg-base: #0f0e17;
  --bg-surface: #1a1925;
  --bg-elevated: #221f2e;
  --border-subtle: rgba(255, 255, 255, 0.07);
  --border-default: rgba(255, 255, 255, 0.12);
  --text-primary: #e8e6f0;
  --text-secondary: #8b8a9e;
  --text-muted: #5c5a6e;
  --accent: #6e56cf;
  --accent-hover: #7c66d8;
}

Worth noting: the accent isn't just a purple you grabbed from a color wheel. It's specifically desaturated enough to not vibrate against the dark background. If your accent color glows, it's too saturated. Drop it down until it feels confident instead of excited.

One more thing — don't use opacity to create your muted text variants. Use explicit hex or rgba values. Opacity stacks across parent elements and you'll end up with text that's invisible on slightly elevated surfaces. Learned that the hard way on a client project in mid-2025.

Dense Layout: The 32px Row and How to Use It

The Linear row is 32px tall. That sounds aggressive until you sit with it for a week and then go back to 48px rows elsewhere — suddenly everything feels like it's designed for someone who's never used a keyboard. Dense layout isn't about cramming things in. It's about trusting the user to read.

Here's a basic issue row component. The key is h-8 (32px), text-sm (14px), and tight icon sizing at 14px. Everything else follows from that constraint:

interface IssueRowProps {
  id: string;
  title: string;
  status: 'todo' | 'in-progress' | 'done' | 'cancelled';
  priority: 'urgent' | 'high' | 'medium' | 'low';
  assignee?: string;
}

const statusColors = {
  'todo': 'text-[#5c5a6e]',
  'in-progress': 'text-[#6e56cf]',
  'done': 'text-[#4ea853]',
  'cancelled': 'text-[#5c5a6e] line-through',
};

export function IssueRow({ id, title, status, priority, assignee }: IssueRowProps) {
  return (
    <div className="flex items-center h-8 px-3 gap-2 hover:bg-white/[0.04] cursor-pointer group rounded-sm">
      <span className="text-xs text-[#5c5a6e] w-14 shrink-0 font-mono">{id}</span>
      <PriorityIcon priority={priority} size={14} />
      <StatusIcon status={status} size={14} />
      <span className={`flex-1 text-sm truncate ${statusColors[status]}`}>
        {title}
      </span>
      {assignee && (
        <Avatar name={assignee} size={18} className="shrink-0" />
      )}
    </div>
  );
}

Notice there's no border between rows. Linear uses hover:bg-white/[0.04] as the only interactive signal. That 4% white overlay is subtle but it's enough — your eye tracks it fine. Adding borders between rows would add visual noise that undermines the density.

Quick aside: group your rows inside a container that has divide-y divide-white/[0.05] if you absolutely need visual separation — but only for grouped sections like "Today" vs "This Week". Don't do it for every row.

In practice, this layout only works if your typography is tuned. Use a geometric sans-serif — Inter at 13px or 14px. Avoid system fonts here because the metrics vary too much across platforms and your 32px rows will start clipping descenders on Windows.

Building the Command Palette

The command palette is the feature that makes keyboard-first UIs worth the effort. Hit Cmd+K, type two letters, press Enter — you're there. No mouse, no nav hierarchy. Linear's implementation opens in roughly 80ms and that speed is part of the product.

You've got two good options in React: build on top of cmdk (the library Linear's team open-sourced) or roll your own with useEffect + dialog. For most SaaS apps, cmdk is the right call. It handles fuzzy search, keyboard navigation, and accessibility in about 200 lines you don't have to write:

import { Command } from 'cmdk';
import { useState, useEffect } from 'react';

export function CommandPalette() {
  const [open, setOpen] = useState(false);

  useEffect(() => {
    const handleKey = (e: KeyboardEvent) => {
      if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
        e.preventDefault();
        setOpen(prev => !prev);
      }
    };
    document.addEventListener('keydown', handleKey);
    return () => document.removeEventListener('keydown', handleKey);
  }, []);

  return (
    <Command.Dialog
      open={open}
      onOpenChange={setOpen}
      className="fixed inset-0 z-50 flex items-start justify-center pt-[20vh]"
      overlayClassName="fixed inset-0 bg-black/60 backdrop-blur-sm"
    >
      <div className="w-full max-w-[560px] bg-[#1a1925] border border-white/[0.12] rounded-xl overflow-hidden shadow-2xl">
        <Command.Input
          placeholder="Type a command or search..."
          className="w-full px-4 py-3 text-sm bg-transparent border-b border-white/[0.08] text-[#e8e6f0] placeholder:text-[#5c5a6e] outline-none"
        />
        <Command.List className="max-h-[320px] overflow-y-auto p-1">
          <Command.Empty className="py-6 text-center text-sm text-[#5c5a6e]">
            No results found.
          </Command.Empty>
          <Command.Group heading="Navigation" className="text-xs text-[#5c5a6e] px-2 pt-2 pb-1">
            <Command.Item className="flex items-center gap-2 px-2 py-1.5 text-sm text-[#e8e6f0] rounded-md cursor-pointer aria-selected:bg-white/[0.08]">
              Go to Issues
            </Command.Item>
            <Command.Item className="flex items-center gap-2 px-2 py-1.5 text-sm text-[#e8e6f0] rounded-md cursor-pointer aria-selected:bg-white/[0.08]">
              Go to Projects
            </Command.Item>
          </Command.Group>
        </Command.List>
      </div>
    </Command.Dialog>
  );
}

That backdrop-blur-sm on the overlay is a nice touch — it signals that the rest of the UI is still there, just paused. Don't skip it. The alternative (solid black overlay) makes the palette feel like a modal interrupt rather than a quick shortcut layer.

Performance matters here. If your command list has more than ~200 items, use Command.Group with virtualization or filter server-side. A palette that lags even 100ms after a keystroke breaks the whole keyboard-first illusion you're building.

Focus Mode: Getting Everything Else Out of the Way

Focus mode is Linear's "distraction-free" toggle — hide the sidebar, collapse the header to a thin bar, give the content all 100vw. It sounds trivial until you try to implement it and realize your layout has been fighting layout shifts and sidebar-driven width calculations this whole time.

The cleanest approach is a context + CSS class on body. Keep it out of component state — you want it to work across route changes without re-rendering your whole tree:

// contexts/focus-mode.tsx
import { createContext, useContext, useEffect, useState } from 'react';

interface FocusCtx {
  focused: boolean;
  toggle: () => void;
}

const FocusContext = createContext<FocusCtx>({ focused: false, toggle: () => {} });

export function FocusModeProvider({ children }: { children: React.ReactNode }) {
  const [focused, setFocused] = useState(false);

  const toggle = () => setFocused(prev => !prev);

  useEffect(() => {
    document.body.classList.toggle('focus-mode', focused);
  }, [focused]);

  useEffect(() => {
    const handler = (e: KeyboardEvent) => {
      if (e.key === 'Escape' && focused) toggle();
    };
    window.addEventListener('keydown', handler);
    return () => window.removeEventListener('keydown', handler);
  }, [focused]);

  return <FocusContext.Provider value={{ focused, toggle }}>{children}</FocusContext.Provider>;
}

export const useFocusMode = () => useContext(FocusContext);

Then in your CSS, drive the layout from the body class. This keeps your component logic clean and lets you use CSS transitions for the sidebar collapse:

.app-sidebar {
  width: 240px;
  transition: width 180ms cubic-bezier(0.2, 0, 0, 1),
              opacity 180ms ease;
  overflow: hidden;
}

body.focus-mode .app-sidebar {
  width: 0;
  opacity: 0;
  pointer-events: none;
}

body.focus-mode .app-header {
  height: 32px; /* collapses from 48px */
}

Look, the cubic-bezier(0.2, 0, 0, 1) easing matters. It's the same curve Material Design uses for "standard" transitions and it feels natural for layout changes — fast in, slow out. A linear or ease-in-out transition on a sidebar collapse just looks weird.

Pair focus mode with a keyboard shortcut. Linear uses V while holding no modifier — but that can conflict with text inputs. Safer bet: Ctrl+\ or Cmd+\ — uncommon enough that it won't fire by accident, but easy to remember. Check out how Empire UI's cyberpunk style handles full-screen overlay modes for more pattern inspiration.

Sidebar Navigation: Less Is More

Linear's sidebar is 240px wide and has maybe 8 items on it. That's it. No mega-menus, no collapsible trees with 4 levels of nesting, no hover-triggered flyouts. The navigation is a flat list with section headers. Why does it feel premium? Because every item fits in one line and nothing overflows.

The key implementation detail is the active state. Linear uses a filled bg-[#6e56cf]/20 background with text-accent on the label — not an underline, not a left border. The 20% opacity accent background reads as "selected" without screaming it. Add a 2px left indicator if you want to be more explicit, but honestly, the background alone is usually enough.

function NavItem({ href, icon: Icon, label, active }: NavItemProps) {
  return (
    <a
      href={href}
      className={[
        'flex items-center gap-2 px-2 h-7 text-sm rounded-md transition-colors',
        active
          ? 'bg-[#6e56cf]/20 text-[#a78bfa]'
          : 'text-[#8b8a9e] hover:text-[#e8e6f0] hover:bg-white/[0.05]'
      ].join(' ')}
    >
      <Icon size={14} />
      <span>{label}</span>
    </a>
  );
}

That 28px (h-7) nav item height is not the same as the 32px issue row. Sidebar navigation items are slightly smaller because you're scanning them, not reading them. This is the kind of micro-decision that separates systems that feel designed from ones that feel assembled.

You can see similar attention to navigation density in the dark UI design guide we've written, and in component patterns across the Empire UI library. Worth cross-referencing both if you're building a full SaaS shell.

Putting It Together: A Realistic SaaS Shell

Here's the full layout shell that wires everything together. This assumes Next.js App Router but the structure works anywhere:

// app/layout.tsx (simplified)
import { FocusModeProvider } from '@/contexts/focus-mode';
import { CommandPalette } from '@/components/command-palette';
import { Sidebar } from '@/components/sidebar';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className="dark">
      <body className="bg-[#0f0e17] text-[#e8e6f0] antialiased">
        <FocusModeProvider>
          <CommandPalette />
          <div className="flex h-screen overflow-hidden">
            <Sidebar />
            <main className="flex-1 overflow-y-auto">
              {children}
            </main>
          </div>
        </FocusModeProvider>
      </body>
    </html>
  );
}

The antialiased class on body is non-negotiable for dark UIs. Without it, light text on dark backgrounds gets sub-pixel rendering that looks slightly blurry on most monitors. Also set text-rendering: optimizeLegibility in your global CSS — on dense 13px type it makes a visible difference.

One thing most clones get wrong: scroll behavior. Linear's main content area scrolls independently of the sidebar. The sidebar is sticky. This sounds obvious but if you put overflow: hidden on the wrong container you'll end up with the whole page scrolling together and it feels terrible. The flex h-screen overflow-hidden on the wrapper + overflow-y-auto on main is the right combination.

That said, if you're using this in a dashboard context with lots of data tables, you'll also want to handle horizontal overflow on individual tables rather than letting the entire main area scroll horizontally. Linear solves this by making their tables fill the available width and truncating columns — which is the right call for dense layouts but requires you to design around it from the start, not bolt it on later.

Once your shell is solid, the components you drop into it matter too. The saas-dashboard-ui-design article covers stat cards, data tables, and chart layouts specifically in this style. And if you want to layer in some depth without breaking the minimalist feel, the glassmorphism components work surprisingly well as modal surfaces against the flat dark background.

FAQ

Can I use this dark theme approach with Tailwind's built-in dark mode?

Yes, but you'll want to bypass Tailwind's dark: variant for most of it and use raw hex values instead. The .dark: variant requires a class toggle on html and adds specificity that fights your custom tokens. Just set your variables in :root and use them directly.

What font does Linear actually use?

Linear uses Inter — specifically Inter variable at 13px for UI chrome and 14px for content. They also use a tabular numeral variant (font-variant-numeric: tabular-nums) for any numbers in their tables and issue IDs, which keeps columns from shifting width as values change.

How do I handle keyboard shortcuts without clashing with browser defaults?

Avoid single-letter shortcuts for anything destructive, and always check for focus on text inputs before firing. Wrap every shortcut handler with if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; as the first line — saves you a lot of accidental triggers.

Is this approach accessible?

The color contrast on 13px text at #8b8a9e on #0f0e17 fails WCAG AA — it's about 4.1:1 against the 4.5:1 requirement. Linear accepts this trade-off for secondary text. If you need full compliance, bump secondary text to #9e9db2 or use it only above 16px where the 3:1 large-text threshold applies.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Dark Admin Panel Design: Dense Tables, Sidebars and Status ChipsSaaS Pricing Page Design: Psychology, Layout and Toggle TricksTailwind Pricing Section: 3-Tier Layout with Annual ToggleCyberpunk Design in Tailwind: Neon, Dark and Grid Patterns