RizTech Academy logo
RizTech Academy
Building the InterfaceLesson 2 of 530 min

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

  1. Create all six components in apps/web/src/components/ui/.
  2. Render every Button variant and size on one page.
  3. Pass onClick and disabled to Button without adding them to its props. Confirm they work — that is the ButtonHTMLAttributes type earning its place.
  4. Pass className="w-full" and confirm it merges rather than replacing.
  5. Use Input with a label, a hint and an error. Inspect the DOM and find the aria-describedby pointing at the error's id.
  6. Render two Inputs on one page and confirm their ids differ.
  7. Format 123456789 paise with Money. Confirm you get ₹12,34,567.89 with Indian grouping.
  8. Build a product card from Card, CardBody, Badge and Money.
  9. Import something from product/ into a ui/ 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