Maintainable Tailwind and shadcn/ui · Tutorial

Build a Maintainable Tailwind and shadcn/ui Feature

Follow a guided exercise to build a status panel with existing primitives and semantic tokens.

Verified Source

Learning outcome

You will compose the existing Card and Button so that theme changes and product-copy changes have different homes.

This tutorial assumes you are creating a read-only status panel in the jongminchung repository. The learning goal is to experience ownership boundaries, not to implement a full product workflow.

Prerequisites

  • Work from the repository root.
  • Read apps/web/components.json and DESIGN_SYSTEM.md first.
  • Use the existing Card and Button instead of installing a registry component.
  • Keep the example as a Server Component.
  1. Inspect the current component API

    Read the real exports and variants from source.

    rg -n "export.*Card|buttonVariants" packages/ui/src/components
    pnpm exec shadcn info --json -c apps/web

    This prevents a remembered API from creating an unnecessary wrapper or a variant that does not exist.

  2. Create a product composition in the app

    Let apps/web own copy and product state while shared primitives retain reusable appearance and accessibility.

    import { buttonVariants } from "@jongminchung/ui/components/button";
    import {
      Card,
      CardDescription,
      CardFooter,
      CardHeader,
      CardTitle,
    } from "@jongminchung/ui/components/card";
    import Link from "next/link";
    import type { ComponentProps } from "react";
    
    export function DocumentationHealthPanel({
      className,
      ...props
    }: ComponentProps<typeof Card>) {
      return (
        <Card {...props} className={className}>
          <CardHeader>
            <CardTitle>Documentation contracts are current</CardTitle>
            <CardDescription>
              Locale pairs and internal links passed the latest build.
            </CardDescription>
          </CardHeader>
          <CardFooter>
            <Link
              className={buttonVariants({ variant: "outline", size: "sm" })}
              href="/en/series"
            >
              Browse series
            </Link>
          </CardFooter>
        </Card>
      );
    }

    The composition owns product copy while CardHeader and CardFooter own internal spacing. The primitive owns button focus, size, and colors.

  3. Express visual meaning with semantic tokens

    Use text-muted-foreground for supporting text and the existing button variant for the action. Do not add text-gray-500, bg-white dark:bg-*, or text-white.

    If a new color seems necessary, first test whether an existing role expresses the meaning. Add a new role only when it recurs across the product, together with light and dark providers, a foreground pair, an adapter, and a contract test.

  4. Let the page decide placement only

    At the call site, set external width and grid placement.

    <DocumentationHealthPanel className="max-w-xl lg:col-span-2" />

    If the component accepts className, keep consumer overrides away from internal colors, padding, typography, and interaction states.

  5. Run the nearest verification

    pnpm run fmt:check
    pnpm --filter @jongminchung/web run typecheck
    pnpm --filter @jongminchung/web run test
    pnpm --filter @jongminchung/web run build

    Review light and dark modes at mobile and wide viewports when visuals change. If you edited a shared primitive, expand verification to the UI package and its other consumers.

Confirm the result

  • Product copy and workflow remain in apps/web.
  • The shared primitive has no product-named variant.
  • No raw palette or dynamic class fragment was added.
  • Styling alone did not add "use client".
  • Formatting, types, focused tests, and the build pass.

Next lesson

If an existing screen already contains scattered overrides and raw colors, continue with the audit guide.