The Problem
The portfolio site had grown organically. A shared-ui library existed with
generic components (Button, Alert, Spinner), but the app had its own kit/
directory full of wrappers that re-exported shared-ui components with "app-specific"
additions. The result was a layered mess:
- Typography wrappers:
kit/Heading.tsximportedshared-ui/Heading, added 7 app variants (hero,subtitle,mdxH1–mdxH4), and duplicated the entire rendering logic — polymorphic tag selection,cn()class merging, variant-to-style mapping.kit/Text.tsxdid the same for a single variant (subtitle). - Layout wrappers:
kit/PageLayout.tsxwrappedshared-ui/PageContainerto addas='main'and spacing.kit/Section.tsxwrappedshared-ui/Sectionto flip two default props. - Component wrappers:
kit/Spinner.tsx,kit/ErrorAlert.tsx,kit/SectionLabel.tsx, and others re-exported shared-ui equivalents with minor prop adaptations.
Every wrapper created a maintenance burden: parallel variant sets to keep in sync,
duplicated type definitions, and confusion about where to import from. Consumers
had to know which layer owned which variant. Pages mixed @/components/kit and
@danieljoffe.com/shared-ui imports inconsistently.
The Principles
Before writing any code, I established three rules:
- shared-ui is the single source of truth for all reusable UI primitives — typography, layout, and generic components. It depends only on React and Tailwind CSS. No Next.js APIs.
- No wrappers that only add variants. If a component needs more variants,
add them to shared-ui directly. Kit exists only for components that compose
shared-ui with Next.js-specific APIs (
Link,useRouter,next/image). - Defaults should match the dominant usage pattern. If 15 out of 15 pages use the same spacing, bake it in as the default — don't make every consumer repeat the same props.
The Typography System
The first target was the Heading/Text split. The kit wrappers maintained a
sharedVariants set to decide whether to delegate to shared-ui or render
locally — a runtime check for a compile-time concern.
The fix was simple: move every variant into shared-ui.
Heading: 10 Variants
const variantStyles: Record<HeadingVariant, string> = {
hero: 'text-4xl sm:text-5xl font-bold tracking-tight leading-[1.1]',
detail: 'text-3xl sm:text-4xl lg:text-5xl font-bold tracking-tight',
subtitle: 'text-2xl font-bold tracking-tight',
section: 'text-2xl sm:text-3xl font-bold tracking-tight',
cardTitle: 'text-sm font-semibold',
component: 'text-lg font-semibold',
mdxH1: 'text-2xl font-bold tracking-tight mb-6',
mdxH2: 'text-lg font-semibold mt-12 mb-4 scroll-mt-20',
mdxH3: 'text-sm font-semibold mt-8 mb-3 scroll-mt-20',
mdxH4: 'text-xs font-medium mt-6 mb-2 uppercase tracking-wider',
};Each variant maps to a sensible default HTML element (hero → h1,
cardTitle → h3), overridable via the as prop. The consumer picks
the visual intent; the component handles the semantics.
Text: 10 Variants
const variantStyles: Record<TextVariant, string> = {
body: 'text-sm text-text-secondary leading-relaxed',
bodyLg: 'text-base text-text-secondary leading-relaxed',
subtitle: 'text-lg text-text-secondary leading-relaxed',
cardDescription: 'text-sm text-text-secondary leading-relaxed',
detail: 'text-xs text-text-secondary',
label: 'text-xs font-semibold uppercase tracking-wider text-text-tertiary',
meta: 'text-xs text-text-tertiary',
caption: 'text-sm text-text-tertiary',
helper: 'text-sm text-text-tertiary',
error: 'text-sm text-error',
};With the wrappers gone, every consumer imports directly from shared-ui:
import { Heading } from '@danieljoffe.com/shared-ui/Heading';
import { Text } from '@danieljoffe.com/shared-ui/Text';No kit layer. No delegation. No variant sets to keep in sync.
Layout Primitives With Defaults
The second insight came from looking at the 15 pages that all repeated
className='py-16 lg:py-24 space-y-24' on their PageLayout. If every
consumer passes the same props, those aren't options — they're the default.
PageLayout
export function PageLayout({ children, wide = false, className, ...rest }) {
return (
<PageContainer
as='main'
id='main-content'
size={wide ? 'md' : 'sm'}
className={cn('py-16 lg:py-24 space-y-24', className)}
{...rest}
>
{children}
</PageContainer>
);
}Now a page is just:
<PageLayout>
<Section>...</Section>
<Section>...</Section>
</PageLayout>No props needed for the common case. wide toggles to a wider container
for data-heavy pages. Custom className merges with the defaults via cn().
Section
The shared-ui Section had defaults that didn't match reality: center=true
and overflow='hidden'. Every page overrode both. I flipped the defaults
and added the base horizontal padding that every section needed:
export function Section({
children,
padding = 'none',
center = false,
overflow = 'visible',
className,
...rest
}) {
return (
<section
className={cn('relative px-6 lg:px-0', /* variant classes */, className)}
{...rest}
>
{children}
</section>
);
}Component Promotion
With the typography and layout systems consolidated, the remaining kit components fell into two categories:
Moved to shared-ui (no Next.js deps)
- Kbd — keyboard shortcut badge
- GridBg — decorative CSS grid background
- StructuredData — JSON-LD script injector
- SectionLabel — icon + label + decorative rule
- CTACard — card with heading, description, and action slot
Each was a pure React + Tailwind component that had been in kit only by convention. Moving them to shared-ui made them available to any app in the monorepo.
Kept in kit (Next.js dependent)
- PostCard — uses
next/linkfor client-side navigation - CoverImage — uses
next/imagefor optimization - CompanyLogo — uses
next/image
These genuinely compose Next.js APIs with shared-ui primitives. That's what kit is for.
Deleted entirely
- kit/Heading.tsx, kit/Text.tsx — replaced by shared-ui variants
- kit/PageLayout.tsx, kit/Section.tsx — replaced by shared-ui with better defaults
- kit/ErrorAlert.tsx — replaced by
<Alert variant='error'>+ inline retry button - kit/Spinner.tsx — direct shared-ui import (
label→aria-label) - ClientBadge, ClientSkeleton — React 19 ref prop pattern eliminated the need for client wrappers around server-compatible components
Enforcement
A design system without enforcement is just a suggestion. I added a custom
ESLint rule that bans raw <h1> through <h6> elements in JSX, directing
developers to use the Heading component instead. The rule catches violations
at lint time, not in code review.
Results
- 176 files changed, -4,282 / +2,273 lines — a net reduction of ~2,000 lines
- 31 consumer files updated to import directly from shared-ui
- 11 kit wrapper files deleted
- Zero new dependencies — shared-ui still only requires React + Tailwind
- All 1,266 unit tests pass. All 695 E2E tests pass. Zero hydration errors.
Takeaways
Wrappers that only add variants are a code smell. If the wrapper doesn't add behavior (hooks, state, API calls), the variants belong in the library. The "app-specific" label was just an excuse to avoid touching the shared layer.
Defaults should encode the dominant pattern. When every consumer overrides
a default, the default is wrong. Changing center=true to center=false in
Section eliminated 15 identical prop overrides.
The Rule of Three applies to deletion too. If three wrappers exist just to flip a prop, delete all three and fix the source.
Enforce at the tool level. A lint rule catches more violations than a
convention document. The no-raw-headings rule has already prevented
regressions in the same session it was introduced.