Component Library Notes - Conventions, Styling Tokens, and Composition
Status: Active
Last Updated: 2026-08-26
Category: Frontend - Architecture
Prerequisites: website-rebuild, tailwind-v4-migration
Time: 1 hour
Tags: react, components, tailwind, composition, design-system, conventions
Summary
Working conventions for React components in the fogserv.cloud website src/components/ tree: how to structure and name components, how Tailwind styling tokens are consumed, when to reach for headless UI primitives vs hand-rolled markup, and why composition beats configuration for a small team.
๐ฏ What You'll Learn
By the end of this article, you'll be able to:
- โ Follow the project's component file layout and naming rules
- โ
Use brand tokens (
primary,secondary, accent tan/terracotta) correctly in class strings - โ Decide between adding a prop, composing children, or extracting a primitive
- โ Avoid the classic anti-patterns (prop-explosion wrappers, style duplication, server/client boundary leaks)
Context / Why This Matters
The site is small enough that a formal published component library would be overhead โ but not so small that ad-hoc components stay consistent. These notes codify what already exists in src/components/ so new pages (routing-patterns) reuse rather than reinvent. Styling rides on the Tailwind v4 token layer described in tailwind-v4-migration; this article covers how components consume it.
Implementation / Core Content
Layout and Naming
src/
โโโ components/ # shared UI: buttons, cards, layout shells
โโโ routes/ # page-specific composition (may have local subcomponents)
โโโ hooks/ # reusable behavior (useXxx)
โโโ services/ # API/data clients used by loaders
โโโ types/
Rules:
- Shared โ
src/components/. Used by exactly one route โ keep it in that route's file or a sibling; don't pre-maturely share. - File name = component name, PascalCase (
NewsletterSignupForm.tsx). One primary export per file. - Props interface named
<Component>Props, exported only if consumers need the type. - Server-safe by default under TanStack Start; anything using browser APIs (localStorage, event listeners) must be explicitly client-only and isolated at the leaf of the tree.
Styling Token Consumption
Components never hard-code hex values. They use token-derived utility classes:
// Good โ token classes from @theme/config
<button className="rounded-md bg-primary px-4 py-2 text-white hover:bg-primary-hover">
Subscribe
</button>
// Bad โ bypasses the design system
<button className="bg-[#2f6f5e]">Subscribe</button>
Available families (see tailwind.config.ts today / @theme after tailwind-v4-migration): primary, primary-hover, primary-light, secondary (+hover/light), accent-tan, accent-terracotta (+variants). Semantic guidance:
- primary: main CTAs, active nav state.
- secondary: supporting actions, secondary buttons.
- accent-tan / accent-terracotta: editorial highlights only (pull quotes, badges) โ not interactive controls.
Long class strings are normal with Tailwind; extract a component instead of abstracting the string:
// src/components/Button.tsx
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: 'primary' | 'secondary'
}
export function Button({ variant = 'primary', className, ...rest }: ButtonProps) {
return (
<button
className={`rounded-md px-4 py-2 text-sm font-medium ${VARIANTS[variant]} ${className ?? ''}`}
{...rest}
/>
)
}
const VARIANTS = {
primary: 'bg-primary text-white hover:bg-primary-hover',
secondary: 'border border-secondary text-secondary hover:bg-secondary-light',
} as const
Two variants, not twelve: if you're tempted to add variant="primary-large-outlined", compose instead.
Headless Choices
For interactive primitives, prefer unstyled/headless implementations styled with our tokens over heavyweight UI kits:
| Need | Reach for |
|---|---|
| Dialogs, dropdowns, popovers | Radix primitives (headless, accessible) โ add per-feature, not wholesale |
| Forms | Native elements + controlled inputs; zod validation shared with loaders (zod already a dependency) |
| Tabs/accordions | Details/summary or minimal state โ only adopt a library on second use |
| Tables | Plain table + Tailwind; TanStack Table only when sorting/paging complexity is real (tanstack) |
Rationale: every UI kit imports its own styling opinions; headless keeps accessibility (focus traps, ARIA) while we own appearance. Adopt libraries one primitive at a time and record each adoption here.
Composition over Configuration
Prefer children/slots over boolean props:
// Prefer this:
<Card>
<Card.Header>{post.title}</Card.Header>
<Card.Body><Markdown source={post.content} /></Card.Body>
</Card>
// Over this:
<Card title={post.title} body={post.content} renderFooter={<Tags/>} compact={false} ... />
Heuristic: the third boolean prop is the smell. Boolean-prop combinations create states no designer approved and tests can't cover.
Server data flows down through props/loaders; components stay presentational. Mutations go through route actions/TanStack Query (tanstack), never inside deep child components fetching their own data โ that breaks SSR consistency and duplicates cache keys.
Practical Examples
Example 1: Extracting a repeated pattern
If /kb.index.tsx and /changelog.index.tsx both render article lists, extract ArticleList taking typed items โ not a generic "ListWidget" with config flags. Two concrete usages define the right abstraction better than speculation.
Example 2: Client-isolated widget
'use client';
import { useEffect, useState } from 'react'
export function ThemeToggle() { /* browser-only logic lives here */ }
Used from a server component like any other element; isolation stays at the leaf.
Example 3: Form with shared validation
const schema = z.object({ email: z.string().email() })
// same schema used by the server action/loader โ one definition, two validations
const result = schema.safeParse(formData)
Common Pitfalls & Troubleshooting
| Problem | Cause | Fix |
|---|---|---|
| Same button looks different across pages | Hand-rolled class strings drifting | Use src/components/Button.tsx; add variant only if genuinely needed |
Arbitrary values everywhere (bg-[#...]) |
Contributor didn't know tokens exist | Point them to token list; reject arbitrary hex in review |
| Hydration mismatch warnings | Browser-API access during server render | Move to client component or useEffect |
| Prop-explosion wrapper | Config-style API growth | Refactor to compound/composed children |
| Duplicate fetch logic in components | Data fetching below route level | Hoist to route loader/service; pass via props or loaderData |
| Unused exported components accumulating | No pruning habit | Quarterly sweep: delete anything unreferenced (type-check catches removals) |
Next Steps / Ops Actions
- Sweep
src/components/for arbitrary-value violations before the next release. - Record any new headless primitive adoption in this article's Change Log.
- Cover extracted components with the setup in testing-setup.
- After the
@thememigration lands, verify tokens documented here match CSS reality.
Sources & Related
External references consulted:
- https://www.radix-ui.com/primitives/docs/overview/introduction
- https://tailwindcss.com/docs/styling-with-utility-classes
Related knowledge-base articles:
Change Log
2026-08-26
- Initial creation documenting component conventions, token usage, headless choices, and composition rules.