Building a CSS Design System as a Standalone npm Package
The site you are reading runs on a design system I extracted into a standalone npm package. That sentence is the honest version of a longer story about why ad-hoc styles compound into maintenance debt, what it actually takes to ship CSS as a library, and where the abstraction costs more than you expect.
Why Extract a Design System at All
When I built this site, I made a set of visual decisions: dark backgrounds, a pink and purple accent palette, frosted glass cards, gradient text, a specific typographic scale. Those decisions were expressed as scattered CSS variables and classes across component files. That works fine until you want to apply the same visual language somewhere else, or until you want to change a base color and discover that the value is hardcoded in fourteen different places rather than referenced from a single source of truth.
Design systems extracted from real production sites tend to be more useful than design systems designed in the abstract. The constraints are real. The edge cases already happened. The components already exist. The work is making the implicit decisions explicit, giving them stable names, and packaging them so that other projects can consume them without importing the entire originating codebase.
The tradeoff is that once other projects depend on the package, breaking changes have real costs. That changes how carefully you name things and how conservatively you evolve the API surface.
Design Tokens as the Foundation
Everything starts with CSS custom properties in a single variables.css file. This is not a novel idea, but the discipline of putting every design decision into a token rather than hardcoding values in component styles is what makes the rest of the system composable.
The token categories:
Color palette. Base colors defined as HSL values so you can construct tints and shades programmatically. Semantic aliases (--color-accent-primary, --color-surface-glass) reference the base palette rather than hardcoding hex values. When the base color changes, everything referencing the semantic alias updates automatically.
Typography scale. Font sizes defined on a modular scale with named steps (--text-xs through --text-5xl). Line heights and letter spacing as separate tokens rather than embedded in font shorthand declarations. This keeps the tokens composable. You can apply --text-lg for size and a tighter --leading-tight separately depending on context.
Spacing. A base unit (--space-1 = 4px) with a geometric scale through --space-16. Consistent spacing tokens are the single biggest contributor to visual coherence across unrelated components.
Motion. Duration and easing tokens (--duration-fast, --ease-out-cubic) so that animation timing is consistent and can be adjusted globally without hunting through component stylesheets.
Elevation. Box-shadow definitions at named levels (--shadow-sm through --shadow-xl) built on top of the color tokens so shadow color tracks with theme changes.
All of this lives in one file that every other stylesheet in the package imports. The package's first export entrypoint is this file alone, because some consumers only want the tokens.
Modular Export Architecture
The package uses the exports field in package.json to define separate entrypoints rather than bundling everything into a single file that consumers must import in full.
{
"exports": {
"./variables.css": "./src/css/variables.css",
"./typography.css": "./src/css/typography.css",
"./glassmorphism.css": "./src/css/glassmorphism.css",
"./animations.css": "./src/css/animations.css",
"./components.css": "./src/css/components.css",
"./utilities.css": "./src/css/utilities.css"
}
}
A consumer that only needs the design tokens and typography imports two files. A consumer building a full page imports all six. Nothing forces them to load animation keyframes they will never trigger or glassmorphism utilities they will never use.
The files array in package.json controls what actually lands in the published package. Without it, you publish test fixtures, build scripts, and development-only files to every consumer. I keep the files array explicit and minimal: only the src/css directory and the src/js directory make it into the published artifact.
Glassmorphism Implementation
The frosted glass aesthetic requires several layered CSS properties working together. The core is backdrop-filter: blur(), which blurs whatever is behind the element. This only works when the element has a semi-transparent background because a fully opaque background gives you nothing to see through.
The depth layering works like this:
.glass-card {
background: linear-gradient(
135deg,
hsla(var(--color-surface-hsl), 0.12) 0%,
hsla(var(--color-surface-hsl), 0.06) 100%
);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid hsla(var(--color-accent-hsl), 0.15);
border-radius: var(--radius-lg);
}
The saturate(180%) on the backdrop filter increases color saturation of whatever is behind the glass, which creates a more vivid, distinctly glassy effect compared to blur alone.
The scanline effect is a CSS gradient overlaid on the card surface, specifically a repeating linear gradient with 1px bands at very low opacity. It adds texture without image assets and reinforces the dark aesthetic.
Border treatment matters more than it seems. A semi-transparent border that references the accent color ties the card to the overall palette and creates the illusion of a glowing edge in dark contexts without actual glow effects, which can be expensive to animate.
JavaScript Component Architecture
The package ships interactive JavaScript components as separate ES modules: gallery, carousel, modal, dropdown, tabs, collapse, toast, and countdown. Each component follows the same lifecycle pattern.
Every component exports a class with an init() method that accepts a configuration object and a destroy() method that cleans up completely. The contract: if you call destroy(), the component releases all resources and leaves the DOM in the same state it was in before init() was called.
This contract matters for single-page applications where components mount and unmount frequently. It also matters for testing, because a component that cannot clean up after itself is a component that makes tests order-dependent.
Two internal utility classes enforce the cleanup contract.
EventTracker wraps addEventListener and keeps a registry of every listener attached. When destroy() is called, EventTracker iterates the registry and calls removeEventListener for each entry. No manual tracking in individual components; the infrastructure handles it.
TimerRegistry does the same for setInterval and setTimeout. Every timer a component creates goes through TimerRegistry. Every timer is cleared on destroy. This prevents the common bug where a component that is removed from the DOM continues executing interval callbacks that reference stale DOM nodes or closed-over state.
XSS Hardening
The original versions of several components constructed HTML by interpolating user-controlled data into template strings:
// Before — vulnerable to XSS if content comes from user input
element.innerHTML = `<div class="toast-title">${title}</div>`;
I went through each component and replaced innerHTML interpolation with safe DOM construction:
// After — safe regardless of content
const titleEl = document.createElement('div');
titleEl.classList.add('toast-title');
titleEl.textContent = title;
element.appendChild(titleEl);
The rule I applied: innerHTML is only acceptable for static, developer-controlled strings that never contain runtime data. Anything that accepts external input, such as toast messages, gallery captions, and dropdown labels, uses createElement and textContent. It is more verbose. It is unambiguously safe.
Accessibility
WCAG 2.1 AA compliance shaped several implementation decisions.
Contrast ratios for all text and interactive elements are documented against the dark background palette. The pink accent color that looks correct at large display sizes fails AA contrast at small body text sizes. The solution was a slightly lighter tint for body text use cases and reserving the saturated accent for decorative use only.
Keyboard navigation is implemented for every interactive component. The modal traps focus within itself while open and returns focus to the trigger element on close. Dropdown menus respond to arrow keys, Home, End, and Escape. Tab order follows the visual order.
prefers-reduced-motion is respected throughout the animations stylesheet and in every JS component that animates. When the user has requested reduced motion, transitions collapse to near-zero duration and animated effects are disabled. The countdown component in particular has visible number changes on each tick, and these swap to immediate updates rather than animated transitions when reduced motion is active.
Honest Reflection on Shipping CSS as a Library
Extracting a design system from a working site sounds like the easy part of the work. In practice, it surfaces several problems that do not exist when styles only serve a single codebase.
Breaking changes compound. When the system only serves this site, I can rename a CSS custom property and fix the references in the same commit. When the package has downstream consumers, a rename is a breaking change that requires a major version bump, a migration guide, and coordination with everyone who depends on the old name. This raises the cost of every naming decision and makes you more conservative about the API surface you expose.
Documentation is load-bearing. A component library that is not documented is only usable by the person who wrote it. Writing documentation that is accurate enough to be trusted and detailed enough to be useful is time that competes directly with writing features.
Semver discipline is harder than it sounds. Patch, minor, or major? When does a visual change to a component constitute a breaking change? When is a new token a backward-compatible addition versus a signal that the old token is wrong? These questions do not have clean answers, and getting them wrong in either direction erodes trust in the package.
The result is a design system that the site you are reading actually runs on, published to npm, with modular imports, documented tokens, accessible components, and a cleanup contract that holds up under testing. Whether that was the most efficient path to a personal portfolio site is a separate question.
The package is a personal project. If you are building something similar, the primary references I recommend are the CSS Custom Properties specification and the Node.js package.json exports field documentation. The rest follows from those two foundations.
Related Articles
Building a Public API Without Exposing Your Private Application
How to serve curated public data from a private analytics app by building a completely separate API layer on Cloudflare Workers with Hono, D1, and Chanfana.
The 342x Bug: What Happens When You Sum a Pre-Aggregated Field
A specific data engineering pitfall where summing a pre-aggregated metadata field inflates totals by the group size, hiding in plain sight because every individual value is correct.
API Economics and MCP: Designing Tools for Credit-Metered Threat Intelligence
When building MCP integrations for credit-metered APIs, the interesting design decisions are not about the protocol. They are about cache consistency, rate limiting, and how narrow tool scope changes what an AI assistant can do autonomously.