8 min read

The Hidden Complexity of a 'Simple' Button — Lessons from MUI.

Author
Luxing Li
@luxingli

I once tried to ship a "simple" <button> in a client project. Space didn't trigger click on a non-button element, an <a aria-disabled> still navigated, and SSR produced a ripple mismatch. MUI solves all three in one go—here's how.

Table of Contents

If you don't have resources for a complete Design System, use mature libraries.

A Button that "can go live for customers" is far from <button>Click me</button>. It needs to handle tokens, a11y, keyboard/touch behavior, SSR, variants/themes, animations and performance - a whole set of engineering problems.

This article will expand on MUI's button to explain the production-level complexity of mature components.

// Pitfall: <a aria-disabled> still navigates
<a href="/pay" aria-disabled tabIndex={-1} onClick={(e) => e.preventDefault()}>
  Pay
</a>
// Also guard keyboard:
// onKeyDown: if (e.key === 'Enter' || e.key === ' ') e.preventDefault()

Key insight: Only aria-disabled doesn't prevent navigation—you need tabIndex=-1 + prevent default behavior.

Naive vs Production: What You're Actually Building

AspectNaive <button>Production (MUI Button)
KeyboardEnter/Space fails on non-native elementsButtonBase simulates native semantics
Focus:focus everywherefocus-visible only for keyboard users
Disabled LinkStill clickablearia-disabled + tabIndex=-1 + prevent default
ThemingManual color fillingCSS Vars by variant/color combinations
SSR/AnimClient/server mismatchRipple lazy mounting, respects prefers-reduced-motion

MUI Button Component Hierarchy

Design Layering: Why a Button Needs So Many Layers

1. Design Tokens (Variable Layer)

  • Colors / Font sizes / Spacing / Border radius / Shadows / Animation duration
  • → CSS Variables / TS constants / Figma sync
  • Purpose: Change once, change everywhere; Dark/brand switching with zero copy.

2. Primitives (Primitive Layer)

  • Box / Text / Stack / Flex / Grid / Icon / Surface only care about layout and semantics.
  • Purpose: Can build 80% of UI without relying on third parties.

3. Base Components (Behavior Foundation)

  • ButtonBase / InputBase / Popover / DialogBase / TabsBase ...
  • Handle a11y / keyboard interaction / focus management / touch, styles can be "swapped".
  • Purpose: Encapsulate complex interactions in one place.

4. Themed Components (Themed Usable Layer)

  • Button / Input / Select / Modal / Toast ...
  • Purpose: Design/engineering consistency, clean API, on-demand loading.

5. Patterns / Business Composite Layer

  • SearchBar / FilterPanel / BookingStepper ...
  • Purpose: Reuse business composite components, align experience, improve delivery efficiency.

Core Component Analysis

1. Button: API and Slots

Key Points

  • Align with ButtonGroup context: size/variant/color unified but overridable
  • Slot-based (startIcon / endIcon / loading) instead of arbitrary children assembly:
    • Predictable layout (avoid reflow)
    • Style isolation (each slot has class names)
    • Better accessibility (screen reader order fixed)

Why MUI Does This

  • Default type="button": Prevents accidental form submission—a real bug I've seen in production
  • OwnerState → CSS Variables: Avoids runtime if-else calculations, enables theme switching without re-renders
  • Slot-based API: Predictable layout prevents reflow, better for performance

File Location: packages/mui-material/src/Button/Button.js

function Button(props) {
  const group = useContext(ButtonGroupContext)
  const p = mergeDefaults(group, props) // size/color/variant/disabled etc.
  const classes = useUtilityClasses(p)

  return (
    <ButtonRoot {...p} classes={classes} disabled={p.disabled || p.loading}>
      {p.startIcon && <StartIcon>{p.startIcon}</StartIcon>}
      {p.loading && p.loadingPosition !== 'end' && <Loader />}
      {p.children}
      {p.loading && p.loadingPosition === 'end' && <Loader />}
      {p.endIcon && <EndIcon>{p.endIcon}</EndIcon>}
    </ButtonRoot>
  )
}

2. ButtonRoot: Theme/Variant Bridge

Key Points

  • Use CSS Variables to express themes: --variant-containedBg / --variant-textColor ...
  • Variants (text/outlined/contained) and colors (primary/secondary/...) Cartesian combinations generated through variants, avoiding runtime if-else calculations
  • Responsive and state (hover/active/disabled/focus-visible) consistent management

Why MUI Uses CSS Variables

  • Theme switching: No re-renders needed, just CSS variable updates
  • Variant combinations: Pre-generated combinations avoid runtime if-else calculations
  • State consistency: Hover/active/disabled states use same variables

File Location: packages/mui-material/src/Button/Button.js

const ButtonRoot = styled(ButtonBase)(({ theme, ownerState }) => ({
  ...theme.typography.button,
  borderRadius: theme.shape.borderRadius,
  transition: theme.transitions.create(['background-color','box-shadow','color']),
  variants: [
    // Variants
    { 
      props: { variant: 'contained' }, 
      style: {
        color: 'var(--variant-containedColor)',
        background: 'var(--variant-containedBg)',
        '&:hover': { boxShadow: theme.shadows[4] }
      }
    },
    { 
      props: { variant: 'outlined' }, 
      style: { 
        border: '1px solid var(--variant-outlinedBorder)' 
      }
    },
    // Colors (dynamically generated)
    ...genColors(theme.palette).map(c => ({
      props: { color: c },
      style: {
        '--variant-containedBg': theme.palette[c].main,
        '--variant-containedColor': theme.palette[c].contrastText,
        '--variant-textColor': theme.palette[c].main
      }
    })),
  ]
}))

3. ButtonBase: Behavior Foundation

Key Points

  • Keyboard accessibility: Space/Enter handling consistent with native buttons (simulate when non-button elements)
  • Focus ring: only show for keyboard (focus-visible strategy)
  • Ripple control: on-demand mounting, lazy loading, touch/mouse debouncing
  • Link disabled state: aria-disabled + tabIndex=-1 + role='button' (<a> has no disabled)

Why MUI Handles Non-Native Elements

  • Component flexibility: <Button component="a"> should behave like a button
  • Keyboard simulation: Space/Enter must work consistently across all elements
  • Focus management: Only keyboard users see focus rings, not mouse users

File Location: packages/mui-material/src/ButtonBase/ButtonBase.js

function ButtonBase({
  component = 'button', 
  disableRipple, 
  disabled, 
  onClick, 
  ...rest
}) {
  const ref = useRef(null)
  const [focusVisible, setFocusVisible] = useState(false)
  const ripple = useLazyRipple()

  // Keyboard: non-native buttons need to simulate Space/Enter
  const isNonNative = component !== 'button'
  const onKeyDown = (e) => {
    if (isNonNative && e.key === ' ') e.preventDefault() // Prevent scrolling
    if (isNonNative && e.key === 'Enter' && !disabled) onClick?.(e)
  }
  const onKeyUp = (e) => {
    if (isNonNative && e.key === ' ' && !e.defaultPrevented) onClick?.(e)
  }

  // Focus ring only visible for keyboard
  const onFocus = (e) => isFocusVisible(e.target) && setFocusVisible(true)
  const onBlur = () => setFocusVisible(false)

  // Only mount ripple on client-side
  const enableRipple = !disableRipple && !disabled

  const Comp = component
  const a11y = Comp === 'button'
    ? { type: 'button', disabled }
    : { role: 'button', ...(disabled && { 'aria-disabled': true, tabIndex: -1 }) }

  return (
    <Comp
      {...a11y}
      {...rest}
      ref={ref}
      onFocus={onFocus} 
      onBlur={onBlur}
      onKeyDown={onKeyDown} 
      onKeyUp={onKeyUp}
    >
      {rest.children}
      {enableRipple && <TouchRipple ref={ripple.ref} />}
    </Comp>
  )
}

4. TouchRipple: Animation System

Key Points

  • Multiple ripples coexist; start/stop decoupled from events
  • Mobile fixes: ignore mousedown after touchstart; center mode even size fix
  • prefers-reduced-motion graceful degradation

Why MUI Handles Ripple Complexity

  • Touch feedback: Users expect visual feedback on touch devices
  • Multiple interactions: Ripples can overlap and need proper cleanup
  • Accessibility: Respect user's motion preferences

File Location: packages/mui-material/src/ButtonBase/TouchRipple.js

function TouchRipple(_, ref) {
  const [ripples, set] = useState([])
  
  const start = (e, { center }) => {
    const { x, y, size } = computeFromEvent(e, center)
    set(rs => [...rs, <Ripple key={id()} x={x} y={y} size={size} />])
  }
  
  const stop = () => set(rs => rs.slice(1))
  
  useImperativeHandle(ref, () => ({ start, stop }))
  
  return <span className="ripple-root">{ripples}</span>
}

Accessibility Checklist

Essential A11y Features

  • focus-visible: only show focus ring for keyboard (avoid visual noise for mouse users)
  • Non-native button keyboard behavior: Space/Enter consistent with <button>
  • Link disabled state: aria-disabled + tabIndex=-1 + prevent click when necessary
  • Form safety: default type="button", avoid accidental submission
  • Screen reader semantics: semantic elements first, then supplement with role
  • Reduce animations: respect prefers-reduced-motion

Performance & SSR Considerations

Performance Optimizations

  • 🚀 Ripple only mounts on client-side, avoid SSR DOM mismatch
  • 🚀 Variants/colors use CSS variables + variants, reduce runtime calculations
  • 🚀 useLazyRipple and other lazy loading strategies, event handling uses stable callbacks to avoid re-renders

SSR Best Practices

File Location: packages/mui-material/src/useLazyRipple/useLazyRipple.ts

// Example: Client-side only ripple mounting
const enableRipple = !disableRipple && !disabled && typeof window !== 'undefined'

{enableRipple && <TouchRipple ref={ripple.ref} />}

Practical Conclusion

The Complexity Reality

A Button needs to solve simultaneously:

  • Tokens → theme and variant mapping
  • Focus visibility strategy
  • Keyboard/touch consistency
  • Link disabled semantics
  • SSR boundaries
  • Animation degradation
  • Slot API
  • Context convergence with ButtonGroup

The Bottom Line

It's less about CSS and more about contracts: input modality, semantics, and theme invariants across states.

When teams don't yet have complete Design System and QA resources, choosing mature libraries like MUI is a more stable, faster, and safer path.

Key Takeaways

  1. Production-ready components require extensive engineering considerations
  2. Accessibility is not optional - it's a core requirement
  3. Performance optimization happens at multiple layers
  4. Design systems provide consistency and maintainability
  5. Mature libraries solve problems you haven't even thought of yet

Afterword

I hope this article has been helpful to you.
If you'd like to discuss technical questions or exchange ideas, feel free to reach out: luxingg.li@gmail.com