| name | lucide-style-icons |
| description | Design original SVG icons that match the Lucide icon set used across the Argos frontend — grid, stroke and corner conventions, createLucideIcon wiring, Storybook documentation, 16px verification. Use when a concept has no fitting Lucide icon and needs a custom one, or when reviewing/adjusting the existing custom icons. |
Lucide-style custom icons
Custom icons live in apps/frontend/src/ui/Icons.tsx and are documented in
apps/frontend/src/ui/Icons.stories.tsx. Read both before adding one — new
icons should extend the metaphors already there, not invent parallel ones.
First: prove Lucide doesn't have it
Search https://lucide.dev/icons/ (or grep the export list in
node_modules/lucide-react/dist/lucide-react.d.ts) for the concept and its
synonyms. Only draw a custom icon when nothing fits, and say in the icon's
JSDoc which Lucide icons it borrows from. A custom icon that duplicates a
Lucide drawing is a bug: it will drift from upstream refinements.
Lucide's design language
- Grid: 24×24 viewBox. Keep strokes inside the 20×20 live area
(coordinates 2–22); primary square shapes usually sit in 3–21. Center the
composition optically — the bounding box of all strokes should be roughly
centered.
- Stroke only: 2px stroke, round caps and joins, no fill. Color is
currentColor. createLucideIcon applies all of this — never restate it.
- Corners: 2px radius.
rx="2" on rects; mid-path either the arc
a2 2 0 0 1 -2 2 or the cubic c-1 0-2-1-2-2 official icons use — both
draw the same corner.
- Coordinates: snap to whole pixels. Sub-pixel values only when optical
centering demands it (official icons use
.1/.9 in cubics, nothing
finer).
- Spacing: keep ≥2px of clearance between separate elements so they don't
fuse at small sizes. Strokes may touch or cross only when the drawing
means it (a handle sitting on its line, two layers blending — see
Lucide's
blend).
- Density: at most ~5 elements. The app renders icons at 16px inside
buttons (
*:size-4); a detail thinner than ~2px of gap disappears there.
- Metaphor before geometry: decide what the icon says first, reusing
Lucide's vocabulary — a rounded rect is a document/image, stacked offset
frames are layers (front = bottom-right, back = top-left, as in
copy),
a dashed stroke is a ghost/absence.
Icons that belong to one control
When several icons sit in the same button group, they are one drawing in
several states, not several drawings. Build them from shared geometry
constants and change only the ink — the set then reads as a single control,
and the eye compares the difference instead of re-reading each icon. The
comparison set in Icons.tsx does this: the same two panels every time, solid
for the side on screen and ghosted for the side held back.
Ghosting a shape
To say "this part is not here", dash its outline with strokeDasharray rather
than drawing dash segments by hand. Lucide's own *-dashed icons place each
dash as its own path, which suits a full-size square but leaves a small shape
looking like scattered ticks with open corners.
Round caps eat the gaps: with the standard 2px stroke, a dash of d renders as
d + 2 of ink and a gap of g as g - 2 of space, so "2 4" reads as 4 units
of ink and 2 of gap. Pick the pattern at 16px, not at 24px — too fine a dash
closes up at button size and the ghosted shape becomes indistinguishable from a
solid one, which is the whole distinction the icon exists to make. Render the
candidates side by side at 48/24/16px and choose from that, rather than from the
numbers.
Reusing official fragments
The fastest way to stay on-style is to build from official path data. Each
icon's source is readable at
node_modules/lucide-react/dist/esm/icons/<kebab-name>.mjs — copy fragments
from there (a half-frame from square-split-horizontal, the corner peek from
copy, the handle-on-line from git-commit-vertical, …) and recombine them.
Implementation
import { createLucideIcon } from "lucide-react";
export const MyThingIcon = createLucideIcon("my-thing", [
["rect", { x: "3", y: "3", width: "12", height: "12", rx: "2", key: "back" }],
["path", { d: "M21 12v7a2 2 0 0 1-2 2h-7", key: "front-peek" }],
]);
- Name the icon after the app concept (
baseline-view), kebab-case. The
name becomes the lucide-<name> class on the <svg> — that class is how
Playwright locates icon-only buttons (see CLAUDE.md), so treat it as API
and don't rename casually.
- Every element needs a unique
key (they render as a React list).
- The component accepts all Lucide props (
size, strokeWidth,
className, aria attributes) — no wrapper needed, it drops into Button
like any Lucide icon.
Document and validate
- Add the icon to
Icons.stories.tsx: at 24px with its name, at 16px, and
inside a secondary iconOnly Button (with aria-label) next to the
Lucide icons it will sit beside in the real toolbar.
- Run Storybook (
pnpm run --filter @argos/frontend storybook) and check:
- legibility at 16px — squint test: is it still distinct from its
neighbors in the set?
- stroke weight looks identical to the adjacent Lucide originals;
- nothing clips the viewBox edge; the icon doesn't look off-center;
- both light and dark color schemes.
pnpm run static-checks before finishing, like any change.