# Lucide Style Icons

> 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.

- Skill: `argos-ci/lucide-style-icons` (Agent Skill)
- Install (CLI): `npx skillmds@latest add argos-ci/lucide-style-icons`
- Raw SKILL.md: https://api.skillmd.com/api/skills/argos-ci/lucide-style-icons/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Web & Frontend
- Author: argos-ci (https://skillmd.com/u/argos-ci)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/argos-ci/lucide-style-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

```tsx
import { createLucideIcon } from "lucide-react";

/** What the drawing says, and which Lucide icons it borrows from. */
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

1. 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.
2. 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.
3. `pnpm run static-checks` before finishing, like any change.

