@manishrc/keyline
v0.3.1
Published
Figma-style alignment guides for the browser. Drop a data-keyline attribute on any element — see guides for it on a dev overlay.
Maintainers
Readme
keyline
Figma-style alignment guides for your project — directly in the browser. Committed to the repo, so the whole team designs to the same system.
You're the obsessive designer who felt pain looking at things that didn't align. In Figma or Sketch you set up your keylines, your column grid, your baseline rhythm — and you shipped pixel-perfect.
Now everyone's coding with AI. It's easier than ever to design directly in code. But you lost the helpers your design tool gave you. There's no grid panel in the browser. No baseline rhythm. No margin guides. You eyeball it. The drift creeps in.
keyline brings those helpers back, right over your running app. And because the configuration lives in the repo, every teammate (and every coding agent) follows the same alignment system — automatically.
Drop one component at root, mark your content container with one attribute, press K.
// app/layout.tsx
<Keyline margins={{ width: 24, anchor: "content" }} />
// Mark your content container, anywhere:
<div className="mx-auto max-w-2xl" data-keyline="content">
<Article />
</div>That's it. Press K.
Install
bun add @manishrc/keyline # or npm / pnpm / yarnWhat you get on the keyboard
| Key | Action |
|---|---|
| K | Show / hide overlay (the global key) |
| M | Margin strips |
| C | Column grid |
| B | Baseline rhythm |
| L | Positional lines (declared in config) |
| R | Rulers (drag from a ruler to drop a local guide) |
| B + click | Scope the baseline grid to one component (rest of page dims) |
| B + drag | Nudge the grid phase while scoped |
| Alt + click | Select a measurement reference (then hover to measure between) |
| P | Pin the current measurement |
| ? | Show the keyboard panel |
| Esc | Clear selection / hide the overlay |
The family keys (M / C / B / L / R / ? / Esc) only listen while the overlay is showing. That keeps the host app's keyboard untouched until you press K — then for the duration of overlay-mode, the keyboard belongs to keyline.
You don't need to memorize the keys: the first few times you press K, a small HUD shows them. After that it goes quiet. Recall it any time with ?.
Ignored while you're typing in inputs. Last state persists across reloads. The floating button in the corner is the mouse equivalent — hover for per-family toggles, click to show/hide.
The mental model
Everything lives on one shared overlay above your page. Five families:
- Margins — tinted strips marking the "safe area" inside your content container (Apple HIG / Material style).
- Columns — the column grid, automatically sitting inside the margins (Figma frame model).
- Baseline — horizontal rhythm grid for vertical alignment.
- Positional lines — single guides at fixed coordinates (optional, declared in config).
- Rulers + local guides — drag from the rulers to drop scratch guides for pixel-perfect spot checking. Stored in your browser, scoped to the page.
You only need to know one mechanism: drop <Keyline /> once with your config, mark your content container with data-keyline="content". Margins anchor to that element. Columns auto-anchor to the same place, inset by the margin width. Refactor max-w-2xl to max-w-3xl and everything moves with the className. No second source of truth.
A complete setup
For most apps, this is the whole config:
// app/layout.tsx
import { Keyline } from "@manishrc/keyline";
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
{process.env.NODE_ENV === "development" && (
<Keyline
margins={{ width: 24, anchor: "content" }}
columns={{ count: 12, gutter: 16 }}
baseline={{ step: 4, emphasizeEvery: 4 }}
/>
)}
</body>
</html>
);
}Then on any page's content container:
<div className="max-w-2xl mx-auto px-6 py-8" data-keyline="content">
<PageBody />
</div>Press K. You see margins on each side, a 12-column grid centered between them, a 4px baseline rhythm with major lines every 16px, and a thin ruler along the top and left edges.
Change max-w-2xl to max-w-3xl — the margins and columns follow. The class is the layout AND the guide.
Responsive buckets
Any family accepts per-viewport values, keyed by phone / tablet / desktop (mobile-first fallback, like Tailwind prefixes). Buckets are decided by the viewport's shortest side, so rotating a phone never flips the bucket:
<Keyline
columns={{
phone: { count: 4, gutter: 12 },
tablet: { count: 8, gutter: 16 },
desktop: { count: 12, gutter: 16, maxWidth: 1280 },
}}
/>Rulers & local guides
When the overlay is on, two thin rulers appear along the top and left edges. Drag from a ruler onto the page to drop a guide. The guide snaps to the nearest column edge, baseline line, margin edge, or declared line within 6 px. Hold Shift while dragging to disable snapping.
- Drag an existing guide to reposition it (or off the page, back into the ruler, to delete it).
- Alt-drag a guide to duplicate it (Figma-style — the copy stays put).
- Double-click a guide to delete it.
- Click the corner square (top-left, where the rulers meet) to clear every guide on this page.
Rtoggles the rulers without removing existing guides.
Local guides are scoped to the current path (location.pathname — query strings and hashes are ignored) and persist in localStorage under keyline:local-guides:v1:<path>. They're per-developer — they don't follow you across machines, and they don't end up in the repo. That's intentional: declared lines in your <Keyline> config are the team contract; local guides are your scratch pad.
Visually, declared lines are solid; local guides are dashed so the eye separates "this is the system" from "this is mine."
To turn the whole feature off:
<Keyline rulers={false} />To loosen or tighten the snap:
<Keyline rulers={{ snapDistance: 12 }} />Zero-impact promise
Adding or removing keyline causes zero changes to your app's CSS or layout. Delete the package — your rendered output is pixel-identical.
How:
- No wrappers around your elements.
- No marker children that would shift
:nth-childor:empty. - No refs to manage.
- No imports in production builds.
The only DOM footprint:
- A
data-keyline="..."attribute on YOUR existing element (you add it; you remove it). - One overlay
<div>and one floating button appended to<body>at dev-mode runtime. Removed when the activator unmounts.
Don't style on
[data-keyline]in your app's CSS — it's a tooling marker, not a styling hook.
What you should know
- Production safety. Tree-shaken to zero bytes when
NODE_ENV === "production". - Responsive automatically. Whatever your Tailwind resolves to is what gets measured — no breakpoint config in keyline.
- Route changes. Margins and columns re-anchor automatically when each page mounts its
data-keyline="content"element. Add the attribute on every primary route. - Multiple containers. Several elements with the same
data-keylinevalue are merged at the union of their bounding rects. - Forgot the activator? If keyline sees
[data-keyline]attributes but no<Keyline />mounted, it logs a one-time dev hint.
That's it.
Most apps don't need anything below this line.
If you need more
Don't have a data-keyline element on a page yet
If your activator declares an anchor (margins={{ anchor: "content" }}) but a page is missing the attribute, margins fall back to the viewport edges. Just add data-keyline="content" to that page's container.
Hide the floating button
<Keyline button={false} />
// or move it:
<Keyline button={{ corner: "bottom-left" }} />You can also just drag the button to any corner — the position persists per-developer and overrides the prop.
Margins without an anchor (viewport edges)
If you'd rather margins always sit at the viewport edges, omit anchor:
<Keyline margins={{ width: 24 }} />Columns without margins (standalone)
If you don't use margins but want a centered column grid:
<Keyline columns={{ count: 12, gutter: 16, margin: 24, maxWidth: 1280 }} />The grid centers itself in the viewport with the given margin inset and maxWidth cap.
Positional guides (single lines at fixed coordinates)
For ad-hoc rules not tied to a container:
<Keyline
lines={[
{ left: "50%", label: "midpoint" },
{ top: "4rem", label: "header-end" },
{ bottom: "env(safe-area-inset-bottom)" },
]}
/>Numbers are pixels. Strings are any CSS length the browser understands — "24px", "1.5rem", "50%", "calc(50% - 16px)", "min(100%, 1024px)", "10vw".
Toggle with L or the floating panel.
Different colors
<Keyline color="oklch(70% 0.18 230)" />Sets the keyline / margin hue. Columns keep their own blue by default — override via the theme prop:
<Keyline
color="oklch(70% 0.18 230)"
theme={{ column: "rgba(255, 200, 0, 0.16)" }}
/>Per-element color override:
<div data-keyline="content" data-keyline-color="rgba(255,60,60,0.7)">Auto-invert against any background
<Keyline blend />Uses mix-blend-mode: difference. Works on most backgrounds; can fade on mid-gray gradients.
K is taken in my app
<Keyline hotkeys={{ toggleAll: "shift+k" }} />Combos accept shift, alt, ctrl, cmd. Empty string disables. Family keys (M / C / B / L / R / ?) are likewise overridable — but remember they only fire when the overlay is on, so collisions are usually fine.
Presets
<Keyline preset="12-col" />
// presets: "8pt", "4pt", "12-col", "bootstrap"My design system has a <Container> component
The DS author adds the attribute once:
function Container({ variant, children }) {
const classes = cva(/* … */)({ variant });
return (
<div className={classes} data-keyline={variant}>
{children}
</div>
);
}Consumers don't import keyline. Every <Container> auto-contributes a guide when the app has the activator mounted. If keyline isn't installed, the attribute is inert.
Not using React
import { mount } from "@manishrc/keyline/core";
mount({ margins: { width: 24, anchor: "content" } });Vue / Svelte / Astro: call mount() in the framework's mount hook. The data-keyline attribute works identically.
Reference
<Keyline /> props
| Prop | Type | Default | Description |
|---|---|---|---|
| margins | { width: Offset; anchor?: string } \| ByBucket \| false | — | Tinted strips inside the anchored element. No anchor = viewport edges. |
| columns | { count, gutter, margin?, maxWidth?, fill? } \| ByBucket \| false | — | Column grid. Auto-anchors to margins.anchor when set. |
| baseline | { step: number; emphasizeEvery?: number } \| ByBucket \| false | — | Baseline rhythm grid. |
| lines | PositionalLine[] | [] | Single guides at fixed coordinates. |
| rulers | boolean \| { snapDistance } | true | Top + left rulers, drag to drop local guides. |
| color | string | rgba(236,72,153,0.7) | Project color for keylines and margins. |
| theme | Partial<KeylineTheme> | derived | Per-family color overrides. |
| hotkeys | Partial<KeylineHotkeys> | { toggleAll: "k", ... } | Keyboard shortcuts. |
| preset | "8pt" \| "4pt" \| "12-col" \| "bootstrap" | — | Baseline + columns shortcut. |
| blend | boolean | false | mix-blend-mode: difference overlay. |
| button | boolean \| { corner?: ... } | { corner: "bottom-right" } | Floating control button. |
| zIndex | number | MAX_SAFE_INTEGER | Overlay stacking. |
| startHidden | boolean | true | Start with overlay off. |
data-keyline attributes
| Attribute | Type | Description |
|---|---|---|
| data-keyline | string | Label / anchor name. Multiple elements with the same value are merged. |
| data-keyline-margin | CSS length | Per-element margin width override. |
| data-keyline-color | CSS color | Per-element color override. |
Offset values (used by lines, margins.width, etc.)
| Value | Meaning |
|---|---|
| 24 or "24px" | 24 pixels |
| "1.5rem" | Browser-resolved rem |
| "50%" | Percent of the anchor's width/height |
| "calc(50% - 16px)" | Full CSS math |
| "env(safe-area-inset-bottom)" | Environment variables |
| Any CSS length | Browser-parsed via hidden probe |
Common gotchas
content-visibility: autoon a[data-keyline]element collapses the rect to the size hint when offscreen. Setcontain-intrinsic-sizeto a realistic value.- Shadow DOM: closed shadow roots aren't discoverable via
document.querySelectorAll. Annotate the host element instead. - You see nothing after pressing K: clear
localStorage(keyline:state:v1), or check that your container has non-zero measurable width. - Margins look huge on a narrow viewport: margins suppress when they'd exceed ~40% of the container width. Below that threshold they hide automatically.
- Crisp lines on retina: rendered as 1 device pixel (0.5 CSS px on 2x), matching native iOS dividers.
What it doesn't do
- Doesn't enforce — surfaces.
- Doesn't override your CSS — overlays on
<body>. - Doesn't add anything inside your app's DOM tree — only the attribute YOU wrote, plus one overlay node on
<body>. - Doesn't ship in production — tree-shaken.
For coding agents
Keyline treats agents as first-class users — both at setup and at runtime.
Setup recipe (deterministic — do these in order)
This is the exact sequence to add keyline to a React / Next.js App Router app. Each step is verifiable; don't skip the checks.
Install (use the project's package manager — detect it from the lockfile):
bun add @manishrc/keyline # bun.lockb npm i @manishrc/keyline # package-lock.json pnpm add @manishrc/keyline # pnpm-lock.yamlMount the activator once, at the app root, dev-gated. In Next.js App Router this is
app/layout.tsx; in Vite/CRA it's the root component:import { Keyline } from "@manishrc/keyline"; // ...inside <body>, AFTER {children}: {process.env.NODE_ENV === "development" && ( <Keyline margins={{ width: 24, anchor: "content" }} /> )}<Keyline />already ships"use client", rendersnull, and mounts its own overlay on<body>. You do not need to add"use client"to the layout — importing a client component into a server component is allowed.Mark the content container on each primary route — the element whose width defines the layout (often the one with
max-w-*/mx-auto):<div className="mx-auto max-w-2xl" data-keyline="content">Next.js only — pre-empt the "two Reacts" error. Add this even before you see it; it's the #1 setup failure and harmless if unneeded:
// next.config.ts const nextConfig = { transpilePackages: ["@manishrc/keyline"] }; export default nextConfig;Verify without a browser: start the dev server and confirm the build compiles, then read the geometry programmatically (see runtime section) —
window.keyline.snapshot()returns the resolved geometry once<Keyline />is mounted and a[data-keyline]element is on the page. (It throws a descriptive error if the activator is missing or called server-side — treat that as a setup signal, not a crash.) If you can drive a browser, pressKand confirm an overlay appears.
Failure modes — detect and self-correct
| Symptom | Cause | Fix |
|---|---|---|
| Invalid hook call / "two Reacts" | bun link/monorepo bundles a 2nd React | Add transpilePackages: ["@manishrc/keyline"] to next.config.ts (step 4). For Vite, dedupe React in resolve.dedupe. |
| Cannot read … of undefined at mount() server-side | Called mount() during SSR / in a server component | Use <Keyline /> (it's "use client"), or guard mount() with if (typeof window !== "undefined"). |
| Press K, nothing appears | Stale persisted state, or container has 0 measurable width | Clear localStorage key keyline:state:v1; confirm the [data-keyline] element has non-zero rendered width. |
| One-time console hint: activator missing | [data-keyline] exists but no <Keyline /> mounted | Mount <Keyline /> once at root (step 2). |
| Margins jump to viewport edges on a route | That route's container lacks data-keyline="content" | Add the attribute to that page's container (step 3) — it's per-route. |
| Overlay shows but a marked element is missing | content-visibility: auto collapsed its rect, or it's in a closed shadow root | Set contain-intrinsic-size; for shadow DOM annotate the host element. |
| data-keyline styles leak into the app | Used [data-keyline] as a CSS selector | It's a tooling marker, never a styling hook — remove the selector. |
| Overlay present in the production bundle | Mount wasn't dev-gated | Wrap in process.env.NODE_ENV === "development" (step 2). It tree-shakes to zero only when gated. |
Runtime: reading alignment without screenshots
window.keyline.snapshot()(also exported assnapshot()) returns the resolved geometry — bucket, column edges, baseline lines, margin edges, containers, and per-container drift. An agent can check alignment without screenshots. It throws a descriptive error (not a silent failure) if called server-side or before<Keyline />is mounted — the message tells you which.- Drift transitions are logged:
[keyline] content: x +3 off column (was aligned)— console-reading agents see misalignment appear and disappear as they edit. - The
data-keylineattributes are the shared vocabulary: the same labels humans see in the overlay are the keys agents see in the snapshot.
Coming later
- Touch support (long-press to measure, bottom-sheet controls).
keyline.audit()returning structured violations for CI.- Export a local guide → declared
linesconfig entry (so scratch becomes spec).
MIT.
