UI Components 101: A Practical Component Library in Pure CSS

A UI component is any interface piece designed to be reused with the same markup pattern and styling rules wherever it appears — a button, a card, a badge, a modal. The idea isn't complicated, but most component libraries fall apart at scale for a specific, avoidable reason: no consistent naming convention, which means every new component gets built slightly differently than the last one, and six months in, nobody can predict what a class name does without opening the CSS file to check.

Pick a naming convention before writing the first component

ConventionExampleTrade-off
BEM (Block__Element--Modifier).card__title--largeVerbose but unambiguous about relationships between parts
Utility-firstclass="p-4 rounded-lg bg-white"Fast to write, but markup gets long and styling logic lives in HTML
Flat component classes.card-title, .card-largeSimple to read, but nothing enforces which classes are meant to combine

BEM is used throughout the examples below because it makes a component's internal structure legible from the class name alone — .card__title unambiguously belongs inside .card, without needing to check the HTML nesting to confirm it.

Buttons: the component that reveals bad habits fastest

Buttons are usually the first component built and the most copy-pasted, which means inconsistencies here spread everywhere. A base class plus modifiers keeps every variant sharing the same structural rules:

<button class="btn btn--primary">Save changes</button>
<button class="btn btn--secondary">Cancel</button>
<button class="btn btn--primary" disabled>Processing…</button>

<style>
.btn {
  font: inherit;
  font-weight: 600;
  padding: 10px 20px;
  border-radius: 8px;
  border: 1px solid transparent;
  cursor: pointer;
  transition: background .15s ease;
}
.btn--primary { background: var(--accent); color: #fff; }
.btn--primary:hover { background: color-mix(in srgb, var(--accent) 85%, black); }
.btn--secondary { background: transparent; border-color: var(--border); color: var(--ink); }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
</style>

color-mix() generates the hover shade directly from the accent variable instead of hardcoding a second color — change --accent once and both the base and hover states update together, which a hardcoded hover color would silently miss. The :focus-visible rule matters just as much as the hover state: without it, keyboard users tabbing through the page get no visual indicator of which button is focused.

Cards: get the internal spacing scale right once

<article class="card">
  <img src="thumb.jpg" alt="" class="card__image">
  <div class="card__body">
    <h3 class="card__title">Quarterly Report</h3>
    <p class="card__text">Updated 2 hours ago</p>
  </div>
</article>

<style>
.card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 12px;
  overflow: hidden;
}
.card__image { width: 100%; aspect-ratio: 16/9; object-fit: cover; }
.card__body { padding: 16px; }
.card__title { font-size: 16px; margin-bottom: 4px; }
.card__text { font-size: 13px; color: var(--muted); }
</style>

Every card variant in a library — a stat card, a product card, a profile card — should reuse this same .card base and only differ in what's inside .card__body, rather than each one reinventing its own border-radius and padding values slightly differently.

Badges: small components, easy to get inconsistent

<span class="badge badge--success">Active</span>
<span class="badge badge--warning">Pending</span>
<span class="badge badge--danger">Failed</span>

<style>
.badge {
  display: inline-block;
  font-size: 12px;
  font-weight: 600;
  padding: 3px 10px;
  border-radius: 999px;
}
.badge--success { background: rgba(52,211,153,.15); color: #34d399; }
.badge--warning { background: rgba(251,191,36,.15); color: #fbbf24; }
.badge--danger  { background: rgba(248,113,113,.15); color: #f87171; }
</style>

Status colors are one of the easiest places for a component library to drift — a "success" state built green in one part of the app and a slightly different green three components later, because nobody centralized the palette. Defining --color-success, --color-warning, and --color-danger as root variables and referencing them everywhere prevents this drift entirely.

A modal that handles focus correctly, not just visually

Modals are where accessibility gaps show up most, because a modal that's visually correct but doesn't trap keyboard focus lets a keyboard user tab right through it into the page behind:

<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
  <div class="modal__panel">
    <h2 id="modal-title">Delete this item?</h2>
    <p>This can't be undone.</p>
    <button class="btn btn--secondary">Cancel</button>
    <button class="btn btn--primary">Delete</button>
  </div>
</div>

<style>
.modal {
  position: fixed; inset: 0;
  background: rgba(0,0,0,.5);
  display: flex; align-items: center; justify-content: center;
}
.modal__panel {
  background: var(--surface);
  border-radius: 12px;
  padding: 24px;
  max-width: 400px;
}
</style>

role="dialog", aria-modal="true", and aria-labelledby pointing at the heading are what tell assistive technology this is a modal dialog rather than an arbitrary page section — the visual styling alone communicates nothing to a screen reader. Trapping keyboard focus inside the modal while it's open requires a small amount of JavaScript (cycling Tab between the first and last focusable element), which the CSS above doesn't handle on its own.

A full component set already built this way

UIXDraft's templates include buttons, cards, badges, and modals built with consistent BEM naming and accessible focus states — copy the components you need directly.

See the component library →

The scaling problem nobody notices until component fifteen

Component one and component two always feel fine regardless of naming discipline — inconsistency only becomes visible once a library has enough components that patterns should be recognizable but aren't. A button using .btn-primary next to a badge using .badge--success next to a card using .Card-Title is three different naming conventions in one codebase, and it means every new component built afterward has to guess which pattern to follow instead of having an obvious answer. Picking one convention on day one and enforcing it — even informally, even solo — pays for itself by the time a project has a dozen components.

Testing components in isolation before wiring them into a real page

A component that looks right inside the one page it was built for can still break the moment it's reused somewhere with different surrounding context — a card that assumed it always sits inside a three-column grid suddenly looks wrong stretched full-width in a single-column layout. Building and reviewing each component on its own, against a plain background with no surrounding layout assumptions, catches this kind of context-dependence before it ships. This is the underlying idea behind tools like Storybook, but the same discipline works fine with nothing more than a bare test HTML page listing every component variant side by side.

Frequently Asked Questions

Should UI components be built with CSS classes or CSS-in-JS?

Both work; the right choice depends on the project's existing stack more than any inherent superiority. Plain CSS classes (as shown above) are framework-agnostic and easy to share across projects. CSS-in-JS ties styling to component logic more tightly, which some teams prefer inside a React or Vue codebase specifically for co-location.

How do I keep components visually consistent across a large project?

Centralize every repeated value — colors, spacing, radii, font sizes — as CSS custom properties at the root, and require every new component to reference those variables instead of writing new hardcoded values. A short written style guide listing the approved values also helps once more than one person is contributing components.

Do I need a JavaScript framework to build a reusable component library?

No — plain HTML and CSS components, copy-pasted with consistent class naming, work fine for many projects and avoid framework lock-in entirely. A framework (React, Vue) becomes genuinely useful once components need shared interactive state or you want single-file encapsulation, but it's not a prerequisite for reusability itself.