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

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
- Real-World Pitfall: The Disabled Link That Still Navigates
- Naive vs Production: What You're Actually Building
- MUI Button Component Hierarchy
- Design Layering: Why a Button Needs So Many Layers
- Core Component Analysis
- Accessibility Checklist
- Performance & SSR Considerations
- Practical Conclusion
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.
Real-World Pitfall: The Disabled Link That Still Navigates
// 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
Aspect | Naive <button> | Production (MUI Button) |
---|---|---|
Keyboard | Enter/Space fails on non-native elements | ButtonBase simulates native semantics |
Focus | :focus everywhere | focus-visible only for keyboard users |
Disabled Link | Still clickable | aria-disabled + tabIndex=-1 + prevent default |
Theming | Manual color filling | CSS Vars by variant/color combinations |
SSR/Anim | Client/server mismatch | Ripple 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
- Production-ready components require extensive engineering considerations
- Accessibility is not optional - it's a core requirement
- Performance optimization happens at multiple layers
- Design systems provide consistency and maintainability
- 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