Carl Sagan once famously said, "If you want to make an apple pie from scratch, you must first invent the universe." If Sagan had been a frontend engineer in 2024, he might have said something much more painful: "If you want to create a button from scratch, you must first bootstrap a runtime, configure a bundler, resolve peer dependency conflicts, write a design token parser, and ensure AAA accessibility compliance."
This week, a philosophical but deeply practical post titled "If you want to create a button from scratch, you must first create the universe" hit the top of Hacker News. It struck a massive chord with developers. Why? Because we are all feeling the crushing weight of the modern web stack. What used to be a simple <button>Click me</button> has evolved into an architectural decision requiring an engineering degree.
Today, we're going to dive deep into the "universe" behind a simple modern button. We’ll look at why the abstraction layers exist, evaluate when they help versus when they hurt, and look at a practical, modern approach to building resilient, highly accessible, and high-performance UI components without losing our minds (or bloating our node_modules to cosmic proportions).
The Anatomy of a Modern "Button"
To understand how we got here, let’s look at what a "simple" button actually has to do in a modern, production-grade enterprise application. It’s no longer just about handling a click event. A production-ready button must support:
- Design System Consistency: Variants (primary, secondary, outline, ghost), sizes, loading states, and dark mode support.
- Accessibility (a11y): Correct ARIA roles, keyboard navigation (handling space/enter keys), focus traps when inside modals, and screen reader announcements.
- Touch and Gesture Handling: Mitigating the 300ms mobile click delay, handling hover states on touch screens (which stick annoyingly), and multi-touch prevention.
- Performance: Zero-runtime CSS-in-JS or highly optimized utility CSS, combined with tree-shaking compatibility.
When you try to code all of this from scratch, your simple 10-line HTML element turns into a 250-line React/TypeScript beast. Let's look at the "Cosmic Dependency Chain" that makes this happen.
The Cosmic Dependency Chain (The Developer's Universe)
When you run npm init and start building a modern component, you aren't just writing code; you are summoning an entire ecosystem of tooling. Here is a high-level view of the stack supporting our humble button:
┌────────────────────────────────────────────────────────┐
│ Your App Component │
└──────────────────────────┬─────────────────────────────┘
▼
┌────────────────────────────────────────────────────────┐
│ UI Library / Framework (React/Vue) │
└──────────────────────────┬─────────────────────────────┘
▼
┌────────────────────────────────────────────────────────┐
│ Accessibility / Primitive Layer (Radix/Aria) │
└──────────────────────────┬─────────────────────────────┘
▼
┌────────────────────────────────────────────────────────┐
│ Styling Engine (Tailwind CSS / Vanilla Extract)│
└──────────────────────────┬─────────────────────────────┘
▼
┌────────────────────────────────────────────────────────┐
│ Build Pipeline (Vite / Rollup / ESBuild) │
└──────────────────────────┬─────────────────────────────┘
▼
┌────────────────────────────────────────────────────────┐
│ OS / Runtime Engine (Node / Bun) │
└────────────────────────────────────────────────────────┘
If any single node in this graph breaks, your button doesn't render, or worse, it renders but fails silently for users relying on screen readers. This is the "universe" we have built for ourselves.
Building a Robust Button: The "Do It Yourself" Cosmic Blueprint
Let's look at how we can build a truly robust, modern button that handles styling, focus states, and loading states, while keeping our dependency universe as small as possible. We will use React and Tailwind CSS for this example, focusing on writing clean, semantic code that doesn't rely on bloated third-party component libraries.
Step 1: The TypeScript Interface
First, we need to ensure our button behaves like a real HTML button. We must extend the native button attributes so our component can accept standard props like type="submit", disabled, or aria-describedby without us having to redefine them manually.
import React, { forwardRef } from 'react';
import { VariantProps, cva } from 'class-variance-authority';
// We use cva to manage our design system variants cleanly
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none active:scale-[0.98] transition-transform duration-100",
{
variants: {
variant: {
primary: "bg-indigo-600 text-white hover:bg-indigo-700 focus-visible:ring-indigo-500",
secondary: "bg-slate-100 text-slate-900 hover:bg-slate-200 focus-visible:ring-slate-500",
outline: "border border-slate-300 bg-transparent hover:bg-slate-50 focus-visible:ring-slate-500",
},
size: {
sm: "h-9 px-3 text-xs",
md: "h-10 px-4 py-2",
lg: "h-11 px-8 text-base",
},
},
defaultVariants: {
variant: "primary",
size: "md",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
isLoading?: boolean;
}
Step 2: Implementing Assembly & Accessibility
Now, let's write the component itself. We must ensure that we correctly forward the React ref (crucial for focus management inside layouts), handle the loading state gracefully, and prevent double-clicks when the button is in an active API request cycle.
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, isLoading, children, disabled, ...props }, ref) => {
return (
<button
className={buttonVariants({ variant, size, className })}
ref={ref}
disabled={disabled || isLoading}
aria-busy={isLoading}
aria-live="polite"
{...props}
>
{isLoading ? (
<>
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4 text-current"
fill="none"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Loading...
</>
) : (
children
)}
</button>
);
}
);
Button.displayName = 'Button';
Why Did We Write All This Code For A Button?
Looking at the code above, we imported a variant manager (class-variance-authority), wrote custom SVG spinner logic, handled references, managed a manual loading state, and mapped dynamic class strings using Tailwind.
If you're a backend engineer or a systems programmer, this looks like absolute madness. Why not just write <button class="btn">?
The answer lies in the diversity of the runtime environment. Unlike building an iOS app where Apple defines the UI guidelines, rendering pipelines, and hardware specifications, web developers compile their code to a target they do not control: the user's browser. Your button might be rendered on a 2015 low-end Android phone running an outdated Chrome WebView, or on a high-end Mac Pro running Safari, or by a screen reader used by a visually impaired user. This unpredictability is what forces us to build "universes" of abstractions to guarantee a consistent, accessible experience.
Finding Balance: Guarding Your Dependency Budget
While abstractions are necessary, it is easy to let our "universe" spin out of control. When we rely on massive UI libraries (like Material UI, Chakra, or full bootstrap frameworks) just to get a button, we import megabytes of JavaScript that must be parsed by the client.
To keep your app fast and maintainable, follow these rules of engagement when building UI components:
1. Embrace Headless UI Primitives
If you need highly complex components (like dropdown menus, select fields, or modals), do not write them from scratch, but do not import heavy pre-styled libraries either. Use "headless" primitive libraries like Radix UI, React Aria, or Ark UI. These libraries provide the "logic universe" (accessibility, focus state mechanics, keyboard handlers) while leaving the "styling universe" entirely to you.
2. Treat CSS as a First-Class Citizen
Avoid CSS-in-JS libraries that compute styles at runtime (like older versions of Styled Components). They require the client browser to execute JavaScript just to paint a button. Instead, use static-extraction styling tools like Tailwind CSS, Vanilla Extract, or modern CSS Modules. This ensures your button renders instantly, even before the JS bundle finishes downloading.
3. Use Bundle Visualizers
Make it a habit to analyze what you are shipping to your users. Add a bundle visualizer (like rollup-plugin-visualizer or webpack-bundle-analyzer) to your build pipeline. If your button component is pulling in 50kb of unused helper functions, it's time to refactor.
Conclusion: The Universe is Ours to Govern
The modern web stack is complex because the web is trying to be an operating system, a document reader, and an application platform all at once. Creating a button from scratch does feel like creating the universe, but as developers, we are the architects of that universe. By choosing lightweight primitives, embracing native browser standards, and keeping our bundler configurations clean, we can build web apps that are incredibly rich and functional without sinking under the weight of our own dependencies.
What does your UI component stack look like? Do you prefer writing raw CSS and HTML, or do you reach for headless UI libraries right away? Let me know in the comments below, or hit me up on Twitter/X at @sysseder!