The problem
The portfolio site had grown organically. There was a shared-ui library with
generic components (Button, Alert, Spinner), but the app also had its own kit/
directory full of wrappers that re-exported those same components with
"app-specific" additions bolted on. 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 was its own little tax: parallel variant sets to keep in sync,
duplicated type definitions, and constant confusion about where to import from.
Consumers had to know which layer owned which variant, and pages ended up mixing
@/components/kit and @danieljoffe/shared-ui imports with no real logic to it.
The principles
Before I wrote any code, I set 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 kept a
sharedVariants set around to decide, at runtime, whether to delegate to
shared-ui or render locally: a runtime check standing in 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 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), and you can override it with the as prop. The consumer
picks the visual intent; the component takes care of 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/shared-ui/Heading';
import { Text } from '@danieljoffe/shared-ui/Text';No kit layer, no delegation, no variant sets to keep in sync.
Layout primitives with defaults
The second realization came from staring at the 15 pages that all repeated
className='py-16 lg:py-24 space-y-24' on their PageLayout. When every single
consumer passes the same props, those aren't options anymore; 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, and a 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', both of which every page overrode anyway. So I flipped
the defaults and folded in the base horizontal padding 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 leftover kit components sorted themselves into two piles:
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 one was a pure React + Tailwind component that had been sitting in kit purely out of habit. 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, which is exactly 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 strongly worded suggestion. So I
added a custom ESLint rule that bans raw <h1> through <h6> elements in JSX
and points developers at the Heading component instead. The rule catches
violations at lint time, well before they reach 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 real behavior (hooks, state, API calls), those 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 the thing that's wrong. Flipping center=true to
center=false in Section deleted 15 identical prop overrides in one go.
The Rule of Three applies to deletion too. If three wrappers exist just to flip a prop, delete all three and fix the source instead.
Enforce at the tool level. A lint rule catches far more than a convention
doc ever will; the no-raw-headings rule had already caught regressions in the
same session I introduced it.
