A small component library you will actually reuse
Repeating the same twelve Tailwind classes across forty buttons is how an interface drifts. This lesson builds a small set of components — complete and working, ready to paste into your project — that the rest of the course uses.
Not a design system. Six components that stop you repeating yourself.
Button
// apps/web/src/components/ui/button.tsx
import clsx from "clsx";
type Props = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "secondary" | "ghost" | "danger";
size?: "sm" | "md" | "lg";
};
const VARIANTS = {
primary:
"bg-emerald-600 text-white hover:bg-emerald-700 focus-visible:ring-emerald-500",
secondary:
"bg-white text-gray-900 ring-1 ring-gray-300 hover:bg-gray-50 focus-visible:ring-gray-400",
ghost:
"text-gray-700 hover:bg-gray-100 focus-visible:ring-gray-400",
danger:
"bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-500",
} as const;
const SIZES = {
sm: "px-3 py-1.5 text-sm",
md: "px-4 py-2 text-sm",
lg: "px-6 py-3 text-base",
} as const;
export function Button({
variant = "primary",
size = "md",
className,
...rest
}: Props) {
return (
<button
className={clsx(
"inline-flex items-center justify-center gap-2 rounded-lg font-medium",
"transition-colors focus-visible:outline-none focus-visible:ring-2",
"focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
VARIANTS[variant],
SIZES[size],
className
)}
{...rest}
/>
);
}
Four decisions worth naming, because they apply to every component here.
React.ButtonHTMLAttributes<HTMLButtonElement> in the type. Now
onClick, type, disabled, aria-label and everything else a button accepts
work without listing them. This is how you build components that behave like the
elements they wrap.
...rest spread last, so callers can override anything.
className accepted and merged last. A caller needing w-full for one case
should not need a new variant.
Complete class names in a lookup object, never built by interpolation — the Tailwind rule from the last lesson.
<Button>Add to cart</Button>
<Button variant="secondary" size="sm">Cancel</Button>
<Button variant="danger" onClick={handleDelete}>Remove</Button>
<Button className="w-full" type="submit">Place order</Button>
Input
// apps/web/src/components/ui/input.tsx
import clsx from "clsx";
import { useId } from "react";
type Props = React.InputHTMLAttributes<HTMLInputElement> & {
label: string;
error?: string;
hint?: string;
};
export function Input({ label, error, hint, className, id, ...rest }: Props) {
const generatedId = useId();
const inputId = id ?? generatedId;
const errorId = `${inputId}-error`;
const hintId = `${inputId}-hint`;
return (
<div className="space-y-1.5">
<label htmlFor={inputId} className="block text-sm font-medium text-gray-900">
{label}
</label>
<input
id={inputId}
aria-invalid={error ? true : undefined}
aria-describedby={clsx(error && errorId, hint && hintId) || undefined}
className={clsx(
"block w-full rounded-lg px-3 py-2 text-sm",
"ring-1 focus:outline-none focus:ring-2",
error
? "ring-red-400 focus:ring-red-500"
: "ring-gray-300 focus:ring-emerald-500",
className
)}
{...rest}
/>
{hint && !error && (
<p id={hintId} className="text-xs text-gray-500">{hint}</p>
)}
{error && (
<p id={errorId} className="text-xs text-red-600">{error}</p>
)}
</div>
);
}
useId() generates a unique id that is stable between server and client, so
htmlFor can point at the input without you inventing ids. Do not use
Math.random() here — it causes the hydration mismatch from module 3.
The label is a required prop. An input without a label is unusable with a screen reader, and making it required means you cannot forget. A visually hidden label is the answer when the design has no room:
<Input label="Search products" className="..." />
aria-describedby links the error to the input, so a screen reader
announces it. Without it the red text is invisible to anyone not looking at it.
Card
// apps/web/src/components/ui/card.tsx
import clsx from "clsx";
export function Card({ className, ...rest }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={clsx(
"rounded-xl border border-gray-200 bg-white shadow-sm",
className
)}
{...rest}
/>
);
}
export function CardBody({ className, ...rest }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={clsx("p-4", className)} {...rest} />;
}
Two small components rather than one with a padded prop — the composition
lesson's argument. A card containing a full-bleed image needs no padding on that
part.
Badge
// apps/web/src/components/ui/badge.tsx
import clsx from "clsx";
const TONES = {
neutral: "bg-gray-100 text-gray-700",
success: "bg-emerald-100 text-emerald-800",
warning: "bg-amber-100 text-amber-800",
danger: "bg-red-100 text-red-800",
} as const;
export function Badge({
tone = "neutral",
children,
}: {
tone?: keyof typeof TONES;
children: React.ReactNode;
}) {
return (
<span
className={clsx(
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
TONES[tone]
)}
>
{children}
</span>
);
}
keyof typeof TONES types the prop from the object, so adding a tone updates
the type automatically and a typo is a compile error.
Money
Formatting currency in six places is how three of them end up different.
// apps/web/src/components/ui/money.tsx
export function formatPaise(paise: number): string {
return new Intl.NumberFormat("en-IN", {
style: "currency",
currency: "INR",
minimumFractionDigits: 2,
}).format(paise / 100);
}
export function Money({ paise, className }: { paise: number; className?: string }) {
return <span className={className}>{formatPaise(paise)}</span>;
}
Intl.NumberFormat with en-IN produces Indian digit grouping — ₹12,34,567.89
rather than ₹1,234,567.89. Writing that by hand is a mistake people make and
customers notice.
One function, used everywhere. The conversion from paise happens here and nowhere else, which is how the integer-paise decision from module 1 stays intact.
EmptyState
// apps/web/src/components/ui/empty-state.tsx
export function EmptyState({
title,
description,
action,
}: {
title: string;
description?: string;
action?: React.ReactNode;
}) {
return (
<div className="py-16 text-center">
<p className="font-medium text-gray-900">{title}</p>
{description && (
<p className="mx-auto mt-1 max-w-sm text-sm text-gray-600">{description}</p>
)}
{action && <div className="mt-4">{action}</div>}
</div>
);
}
Every list needs one, from the lists lesson. Having a component makes it harder to skip.
Where to put them
apps/web/src/components/
ui/ generic, no business knowledge
button.tsx
input.tsx
card.tsx
badge.tsx
money.tsx
empty-state.tsx
product/ knows about products
product-card.tsx
product-grid.tsx
The ui folder must not import from product. Generic components know
nothing about your domain; that is what makes them reusable. The dependency
runs one way, and keeping it that way is worth being strict about.
When to stop
Six components is enough to start. Add one when you have written the same markup three times — the composition lesson's rule.
Do not build a component library in advance. A Modal written before any
screen needs one will be wrong, because you designed it against an imaginary
requirement.
For anything genuinely complex — a combobox, a date picker, a modal with focus trapping — use a headless library such as Radix or React Aria rather than writing it. Accessible versions of those are much harder than they look, and getting focus management wrong makes a component unusable by keyboard.
Check your work
Why ...rest goes last: so props a caller passes override the defaults. Put
it first and your className would win over theirs.
Why className is merged rather than replaced: a caller needing one
adjustment — w-full — should not need a new variant.
Why useId rather than Math.random(): the server and client must generate
the same id, or React reports a hydration mismatch.
What aria-describedby does: links the error message to the input so a
screen reader reads it when the field is focused. Colour alone communicates
nothing to a blind user.
Why formatPaise is one function: so every price in the application is
formatted identically and the paise conversion exists in exactly one place.
Why ui must not import from product: the moment it does, the component
is no longer generic and cannot be reused in another context.
Practice
- Create all six components in
apps/web/src/components/ui/. - Render every
Buttonvariant and size on one page. - Pass
onClickanddisabledtoButtonwithout adding them to its props. Confirm they work — that is theButtonHTMLAttributestype earning its place. - Pass
className="w-full"and confirm it merges rather than replacing. - Use
Inputwith a label, a hint and an error. Inspect the DOM and find thearia-describedbypointing at the error's id. - Render two
Inputs on one page and confirm their ids differ. - Format
123456789paise withMoney. Confirm you get₹12,34,567.89with Indian grouping. - Build a product card from
Card,CardBody,BadgeandMoney. - Import something from
product/into aui/component. Then explain what you have broken, and undo it.
Next: laying those cards out in a grid that works on a real phone.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship