Back to blog

Building UI Components Correctly in the Age of AI

What I learned as a frontend engineer by doing things wrong.

16 min read
UI design fundamentalsReact component best practicesSemantic HTMLAccessible components

Key Takeaways:
Let AI write the JSX, but you still need to understand the fundamentals: semantic HTML, defaults like type="submit", ARIA, and keyboard behavior if you want your components to be correct and reliable.

To make this concrete, I’ve also built a small learning project called `no-frills-ui`, and this post walks through the real problems it solves:

  • CSS-variable theming
  • promise-based confirm dialogs
  • centralized layer management

Today, a few lines of prompt are enough to generate a working React component, a form, or even an entire page. Tools can scaffold JSX, props, hooks, and even ARIA attributes faster than any human can type. That makes it very tempting to treat UI development as a copy–paste and glue exercise. Yet the basics still matter, because the hardest bugs are the ones where the code looks fine. Consider the humble <button>. In HTML, a <button> without an explicit type defaults to type="submit" when it’s inside a form. Click it, and the browser will happily submit the form, even if your intent was "just close the dialog", "open a drawer", or "go to the next step." This isn’t a React quirk or a library bug; it’s the platform’s default behavior.

AI tool can spit out:

<button onClick={handleClick}>Next</button>

It works in isolation. Months later, someone wraps that area in a <form> for validation, and "Next" suddenly starts submitting the form. Without knowing the default behavior of <button>, debugging this turns into superstition rather than understanding.

vibe coding meme: telling cursor to fix vs understanding the issue

In this post, I want to go through a few of those fundamentals, the things AI can help you type, but not truly reason about for you. Creating a component seems simple: render some JSX, add some CSS, ship it. But in practice, even a "simple" button or input carries surprising complexity.

Let's take an example

Consider a seemingly trivial task: "Add delete functionality for list items that are rendered in a drawer". For this, the most basic component would be a button. This button needs to:

  • Be reusable: the same button component must work in different contexts: a table, a card, a modal, a sidebar.
  • Render correctly: visually consistent, right size, proper color.
  • Behave correctly: trigger a confirmation dialog when clicked.
  • Stack properly: the confirmation dialog must appear on top of the drawer, with correct z-index handling.
  • Work with forms: the button should behave correctly inside a form, respecting form submission.
  • Support theming: colors should change when the app switches to dark mode.
  • Support SSR: if you render your app on the server, the button must hydrate correctly on the client.
  • Forward refs and props: consumers might need imperative access or want to pass custom ARIA attributes.
  • Handle accessibility: keyboard users should be able to use it, screen reader users should understand what it does.
  • Support internationalization: text should adapt to different languages without modifying the component.

A single component touches concerns across styling, state management, SEO, accessibility, infrastructure, and composability. This post walks through those considerations using concrete examples: a button, an input, and a dropdown.

The examples use React, but the principles are universal and apply to component libraries built in Vue, Svelte, Angular, or any other framework. The fundamentals of semantic HTML, keyboard navigation, accessibility, and thoughtful API design transcend whatever happens to generate your JSX.

Why these details matter

When building components, you are not just building visual elements. You are building the foundation that other developers will depend on. A component that works perfectly in isolation might break in unexpected ways when combined with others, when rendered on the server, or when used by keyboard-only users.

Semantic HTML gives you a lot for free:

  • Keyboard navigation (Tab, Enter, Space) for buttons, inputs, links.
  • Screen reader semantics (a <button> is announced as a button, a <select> as a combo box).
  • Native form integration (labels, required fields, validation, and submit behavior).
  • Predictable behavior across browsers and SSR.

Using div as button meme

When you throw that away and replace everything with <div> plus onClick, you are re-implementing the browser. That means more code, more bugs, and more edge cases. Exactly the kind of work that AI is bad at validating for you.

Button – small component, big consequences

A button should:

  • Respond to Enter and Space keys.
  • Be announced as a "button" to screen readers.
  • Show clear focus styles.
  • Respect disabled state both visually and semantically.

Implementation:

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: 'primary' | 'secondary' | 'danger';
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant = 'primary', type = 'button', ...props }, ref) => (
    <button
      className={`btn btn-${variant} ${className || ''}`}
      type={type}
      ref={ref}
      {...props}
    />
  )
);

Button.displayName = 'Button';
export default Button;

Key decisions:

  1. Use real <button>: This gives you correct keyboard behavior, semantics, and form integration out of the box.
  2. Default type="button": In HTML, the missing-value default for <button> is submit, which will submit the form when clicked if it’s inside a <form>. Defaulting to type="button" in your component prevents accidental form submissions and forces submit buttons to opt in.
  3. Prop spreading: Extending ButtonHTMLAttributes and spreading ...props lets consumers use any native attribute: disabled, aria-*, form, name, etc.
  4. Ref forwarding: Consumers can focus the button programmatically or integrate it with other imperative APIs.

That type="button" decision is a good example of basics that AI won’t catch for you: it will happily generate a component that works in a Storybook story, but will fail in subtle ways once someone uses it inside a real form.

Input – getting forms right

Inputs are where "div soup" really breaks things. An input should:

  • Be associated with a <label> so assistive tech can announce it.
  • Support browser validation (required, pattern, minLength, etc.).
  • Integrate with form libraries and password managers.
  • Communicate errors clearly to both sighted users and screen readers.

Implementation:

import React, { useId } from 'react';

interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label?: string;
  error?: string;
}

const Input = React.forwardRef<HTMLInputElement, InputProps>(
  ({ className, error, id, label, ...props }, ref) => {
    const reactId = useId();

    const inputId = id || `input-${reactId}`;

    return (
      <div className="input-wrapper">
        {label && <label htmlFor={inputId}>{label}</label>}
        <input
          id={inputId}
          className={`input ${error ? 'input--error' : ''} ${className || ''}`}
          ref={ref}
          aria-invalid={!!error}
          aria-describedby={error ? `${inputId}-error` : undefined}
          {...props}
        />
        {error && (
          <span id={`${inputId}-error`} role="alert">
            {error}
          </span>
        )}
      </div>
    );
  }
);

Input.displayName = 'Input';
export default Input;

Why this works well:

  • Label is automatically associated with the input when provided.
  • Uses a real <input>, so you get native events, validation, autofill, and password manager integration.
  • Uses id plus aria-describedby to connect the error message to the input. Screen readers read them together.
  • Uses role="alert" so new error messages are announced as soon as they appear.

With form libraries like React Hook Form, this component plugs in with almost no extra work.

Dropdowns are where component complexity spikes. There are really three levels:

  1. Native <select> (best default)
  2. Custom dropdown / combobox (when design requires it)
  3. Virtualized dropdown (when data size requires it)

Native `