Bridge Custom Icons in Sync with an Automated Flow
TL;DR: I enjoy to builds reliable design-system tooling. This post shows how I wired a Figma-aligned icon source into a shadcn monorepo so every upstream icon change automatically regenerates React components and lands as a clean PR via GitHub Actions.
Why this matters
In design systems, icon workflows are a common place for small inconsistencies to pile up: duplicated SVGs, drift between source assets and component code, and manual steps that are easy to skip. I built this pipeline to remove those sharp edges.
My intention with this work was to turn icons into a dependable part of the system that is one upstream source for geometry and a workflow that prevents drift by default.
The goal was to make icon usage consistent and easy for product teams, while keeping maintenance predictable as the icon set grows.
The setup
To make this stick as part of a design system, I didn’t want a process that depends on manual human-triggered steps. I wanted a workflow that keeps the repo in a consistent state by default.
So I designed the pipeline around one idea: icons are source + generated output, and both should be updated together.
That translates into:
- Bring the latest SVGs into the repo (sync from the upstream source).
- Run generation in CI with pinned tooling so output is deterministic.
- Regenerate React icon components from those SVGs
- Open one PR that contains the full story: inputs (SVGs) and outputs (TSX + build artifacts), so review is about correctness.
Codegen approach (SVGR)
I chose this codegen approach because I want icons to be composable UI primitives. SVGR is a good fit because it turns SVGs into components with a predictable surface area (props, ref, accessibility hooks).
Implementation-wise, I keep it as a small Node script that:
- reads SVGs from the synced folder,
- runs SVGR with a consistent configuration (and SVGO in the pipeline),
- writes a deterministic output file so diffs stay reviewable.
Libraries
- @svgr/core – the SVG → React/TSX transform engine
- @svgr/plugin-jsx – component shape decisions (props handling, forwardRef, etc.)
- @svgr/plugin-svgo – optimization/normalization so the output stays clean and stable
Output format
The output format is also shaped by design-system constraints. People need a stable import path, consistent naming, and minimal friction when icons change.
So I generate one barrel file (for example ui/icons.generated.tsx) with:
- a clear auto-generated header (so no one edits it by hand),
- named exports (so usage is explicit and tree-shakeable),
- a consistent component shape (props/ref) across the entire set.
Each icon is essentially:
- a function component that returns <svg />
- wrapped with forwardRef
- exported as a named export
Shortened example:
import type { SVGProps } from "react";
import { Ref, forwardRef } from "react";
const IconAlertCircle = (
props: SVGProps<SVGSVGElement>,
ref: Ref<SVGSVGElement>
) => (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
ref={ref}
{...props}
>
<path fill="currentColor" fillRule="evenodd" d="M12 3a9 9 …" clipRule="evenodd" />
</svg>
);
const ForwardRef_IconAlertCircle = forwardRef(IconAlertCircle);
// … export { ForwardRef_IconAlertCircle as IconAlertCircle, … }There are a few design-system details I care about here.
- SVGProps<SVGSVGElement> for a familiar, typed interface
- {...props} so className, aria-*, etc. work naturally
- currentColor so Tailwind text utilities control fill/stroke
- ref on <svg> for advanced composition and animation cases
Consumer experience
This is the part I care about most for adoption. Teams shouldn’t have to think about “how icons are managed”; they should just use icons like any other UI primitive.
So the consumer experience stays simple:
import { IconHeartFilled } from "@/ui/icons.generated";Example:
export function SaveMenuItem() {
return (
<button
type="button"
className="inline-flex items-center gap-2 text-sm text-foreground"
>
<IconHeartFilled className="size-4 shrink-0" aria-hidden />
Save to list
</button>
);
}With a UI button:
import { IconHeartFilled } from "@/ui/icons.generated";
import { Button } from "@/ui/button";
export function Example() {
return (
<Button variant="outline" className="gap-2">
<IconHeartFilled className="size-4 shrink-0" aria-hidden />
Menu
</Button>
);
}GitHub Actions: regenerate icons in the same PR
The CI piece isn’t “automation for automation’s sake”. It’s how you prevent drift. If generation happens locally or inconsistently, the repo slowly stops reflecting what the product actually uses.
So the key decision is to have CI regenerate everything after syncing SVGs and then open a PR that includes the results. That way, the PR is the single checkpoint where you review changes to both assets and components.
Skeleton job:
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- id: sync_icons
uses: your-org/design-tokens/.github/actions/sync-from-repository@<sha>
with:
source-path: icons
destination-path: tokens/harmonized-design-tokens/icons
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build:icons && pnpm registry:build
- uses: your-org/design-tokens/.github/actions/commit-and-open-pr@<sha>
with:
branch: chore/auto-update-icons
paths: tokens/harmonized-design-tokens/icons ui/icons.generated.tsx public/rHere are a few notes from real repos that helped keep this workflow stable over time.
- Pin versions (Node, pnpm, SVGR/SVGO) to keep diffs stable.
- Treat generated output as part of the contract: reviewers can validate “source → output” in one place.
- If diffs get big, that’s usually a sign to improve deterministic ordering (icon sorting) rather than “not generating”.
Takeaway
If SVGs already land in your shadcn repo via a sync workflow, the highest-leverage shift is to treat icon updates like a normal code change:
make CI regenerate components (and any registry output) and ship everything together as one PR.
That single decision keeps the system honest: the repo stays aligned with the source, the generated output stays aligned with the repo, and product teams get a stable component API even as the icon set evolves.