Overview
Project: @danieljoffe/shared-ui — a React 19 + Tailwind 4 component library published to npm and used across this portfolio, WyrdFold, and a public Storybook at ui.danieljoffe.com.
Role: Solo developer
Why it exists: I had two products (the portfolio and WyrdFold) wanting the same Heading, Section, Button, Dropdown, Alert, Card. The Internet Brands case study covers the first time I built a component library — for one application, on one team. This is the case study for the second time: extracting components from one Nx workspace, publishing them as an npm package, and having a different product (WyrdFold, a different repo) consume them.
The interesting problem isn't the components
The components were already there. What made the extraction non-trivial was the build pipeline.
The first attempt — a single bundled dist/index.js — worked locally but broke as soon as another product tried to deep-import a subpath like @danieljoffe/shared-ui/Text. The package's exports field declared a ./* subpath that resolved to per-component TypeScript sources during local development (@danieljoffe.com/source condition for source-level imports inside the workspace), but the published artifact only had one bundled file. So import { Text } from '@danieljoffe/shared-ui/Text' resolved to nothing on npm consumers.
The fix is multi-entry output with Rollup's preserveModules:
// libs/shared/ui/vite.config.ts
import { defineConfig } from 'vite';
import { readdirSync } from 'node:fs';
const entries = readdirSync('./src/lib')
.filter(name => /\.tsx?$/.test(name) && !/\.(spec|stories)\./.test(name))
.map(name => `src/lib/${name}`);
export default defineConfig({
build: {
lib: { entry: ['src/index.ts', ...entries], formats: ['es'] },
rollupOptions: {
output: { preserveModules: true, preserveModulesRoot: 'src' },
},
},
});Now the emitted JS layout matches the per-component .d.ts files that vite-plugin-dts was already producing: every component becomes its own importable subpath, deep-imports work, and tree-shaking falls out for free.
The "use client" directive Rollup keeps stripping
This was the second non-trivial problem. Some components in the library are client-only — they use useState, useRef, or event handlers — and the file starts with 'use client' so Next.js Server Components know to treat them as a client boundary.
Rollup's preserveModules mode strips module-level string directives during the transform pipeline. The emitted chunk is functionally identical, but the directive is gone, and Next.js builds it as a server component. The runtime explodes the first time the component reads from useState.
The fix is a tiny Vite plugin that captures the directive before transforms run and re-emits it on the matching output chunk:
const DIRECTIVE_RE =
/^(?:\s*(?:\/\/[^\n]*|\/\*[\s\S]*?\*\/))*\s*(['"])use (client|server)\1/;
function preserveDirectives(): Plugin {
const directives = new Map<string, string>();
return {
name: 'preserve-use-directives',
enforce: 'pre',
transform(code, id) {
const match = DIRECTIVE_RE.exec(code);
if (match) directives.set(id, `'use ${match[2]}';`);
return null;
},
renderChunk(code, chunk) {
const id = chunk.facadeModuleId;
const directive = id ? directives.get(id) : undefined;
if (!directive) return null;
return { code: `${directive}\n${code}`, map: null };
},
};
}About 20 lines. Reading the directive in the transform hook captures it from the original source, before Rollup has a chance to strip it. The renderChunk hook re-emits the directive at the top of the matching output chunk. Now both 'use client' and 'use server' survive preserveModules, and Next.js can correctly identify client component boundaries across the package.
React 19 changes the ref pattern
The library targets React 19, which makes one API decision easier than the previous decade. forwardRef is on the way out — components can accept ref as a regular prop:
interface ButtonProps extends ComponentPropsWithoutRef<'button'> {
ref?: Ref<HTMLButtonElement>;
variant?: 'primary' | 'secondary' | 'ghost';
}
export function Button({
ref,
className,
variant = 'primary',
...rest
}: ButtonProps) {
return (
<button
ref={ref}
className={cn(buttonStyles({ variant }), className)}
{...rest}
/>
);
}Components that don't use hooks or state work in both server and client contexts without 'use client' boundaries. The package's runtime cost on a server-component-heavy page is close to zero.
Tokens via Tailwind 4
Tailwind 4's @theme block + oklch() color space means the design system tokens live in CSS, not a JS object. Consumers pick up the tokens through Tailwind's own pipeline — there's no separate token export, no JSON file, no theme provider. A consumer that already uses Tailwind 4 inherits the design system by importing the package's theme.css.
What ships across products
The portfolio renders almost every heading, section wrapper, and button through shared-ui. WyrdFold's dashboards consume the same Card, Alert, and Dropdown. The published Storybook is the third consumer — it imports from the same source via the @danieljoffe.com/source condition, so development hot-reloads stay instant inside the monorepo while the published artifact stays stable for outside consumers.
What this case study leaves out
The components themselves — patterns for Dropdown, Toast, Tooltip, focus management — are covered in Building a Design System (v1) and Expanding the Design System (v2). This is the case study about turning those components into a package other products can install.
Takeaway
The interesting engineering wasn't picking React components or color tokens. It was making a workspace package usable from outside the workspace: getting the file layout right, getting the directives to survive the build, and finding the 20-line plugin that fixes the thing Rollup quietly broke. The components are the easy part. The publishing pipeline is where shared design systems live or die.
