CSS Heavy Buttons

Lets try to implement a react button component that uses mostly css.
What We Need
- A root class
.css-button
with CSS variables for colors, spacing, and animation. - Expand on the root class for different button stats with:
:hover
,:active
,:focus
,[disabled]
. - A handful of variants
.primary
,.secondary
,.outline
, etc. - Optional icon and text wrappers.
- Easy theming with zero extra logic.
Styles
/* CSS-heavy Button Component */
.css-button {
/* Default variables */
--button-bg-color: transparent;
--button-border-color: #333;
--button-text-color: #333;
--button-padding: 10px 16px;
--button-border-radius: 12px;
--button-font-weight: 500;
--button-font-size: 1rem;
--button-font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
--button-gap: 8px;
--button-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
--button-hover-bg-color: #333;
--button-hover-border-color: #333;
--button-hover-text-color: white;
--button-hover-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
--button-active-bg-color: #000;
--button-active-border-color: #000;
--button-active-text-color: white;
--button-active-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
--button-selected-bg-color: dodgerblue;
--button-selected-border-color: dodgerblue;
--button-selected-text-color: white;
--button-focus-shadow: 0 0 0 3px rgba(30, 144, 255, 0.5);
appearance: none;
position: relative;
display: flex;
align-items: center;
text-align: center;
gap: var(--button-gap);
padding: var(--button-padding);
width: 100%;
border-radius: var(--button-border-radius);
font-weight: var(--button-font-weight);
cursor: pointer;
overflow: hidden;
background-color: var(--button-bg-color);
border: 1px solid var(--button-border-color);
color: var(--button-text-color);
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
justify-content: center;
box-shadow: var(--button-shadow);
font-size: var(--button-font-size);
font-weight: var(--button-font-weight);
font-family: var(--button-font-family);
}
/* Icon container */
.css-button .icon-container {
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.3s ease;
}
/* Text container */
.css-button .text-container {
font-size: 1rem;
transition: transform 0.3s ease;
}
/* Hover state */
.css-button:hover {
background-color: var(--button-hover-bg-color);
color: var(--button-hover-text-color);
border-color: var(--button-hover-border-color);
transform: translateY(-2px);
box-shadow: var(--button-hover-shadow);
}
.css-button:hover .icon-container {
animation: pulse 0.5s ease infinite alternate;
}
/* Active/pressed state */
.css-button:active {
background-color: var(--button-active-bg-color);
border-color: var(--button-active-border-color);
color: var(--button-active-text-color);
transform: translateY(1px);
box-shadow: var(--button-active-shadow);
}
.css-button:active .icon-container,
.css-button:active .text-container {
transform: scale(0.95);
}
/* Selected state */
.css-button.selected {
background-color: var(--button-selected-bg-color);
border-color: var(--button-selected-border-color);
color: var(--button-selected-text-color);
}
.css-button.selected::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(circle, rgba(255,255,255,0.3) 0%, rgba(255,255,255,0) 70%);
opacity: 0;
animation: ripple 1.5s ease-out infinite;
}
/* Animations */
@keyframes pulse {
0% {
transform: scale(1);
}
100% {
transform: scale(1.1);
}
}
@keyframes ripple {
0% {
transform: scale(0.8);
opacity: 0.5;
}
100% {
transform: scale(1.5);
opacity: 0;
}
}
/* Focus state for accessibility */
.css-button:focus {
outline: none;
}
/* Mobile optimizations */
@media (hover: none) and (pointer: coarse) {
.css-button {
-webkit-tap-highlight-color: transparent;
}
.css-button:hover {
transform: none;
box-shadow: var(--button-shadow);
}
}
/* Button Types */
.css-button.primary {
--button-bg-color: #007bff;
--button-border-color: #007bff;
--button-text-color: white;
--button-hover-bg-color: #0056b3;
--button-hover-border-color: #0056b3;
--button-active-bg-color: #004085;
--button-active-border-color: #004085;
--button-selected-bg-color: #0056b3;
--button-selected-border-color: #0056b3;
}
.css-button.secondary {
--button-bg-color: #6c757d;
--button-border-color: #6c757d;
--button-text-color: white;
--button-hover-bg-color: #5a6268;
--button-hover-border-color: #5a6268;
--button-active-bg-color: #4e555b;
--button-active-border-color: #4e555b;
--button-selected-bg-color: #5a6268;
--button-selected-border-color: #5a6268;
}
.css-button.outline {
--button-bg-color: transparent;
--button-border-color: #6c757d;
--button-text-color: #6c757d;
--button-hover-bg-color: #6c757d;
--button-hover-text-color: white;
--button-active-bg-color: #5a6268;
--button-active-text-color: white;
--button-selected-bg-color: #6c757d;
--button-selected-text-color: white;
}
.css-button.icon-only {
--button-padding: 10px;
--button-border-radius: 50%;
--button-gap: 0;
width: auto;
justify-content: center;
}
.css-button.icon-only .text-container {
display: none;
}
.css-button.large {
--button-padding: 16px 24px;
--button-font-size: 2rem;
}
ok that seems like a good start, now let’s make the component.
import "./Button.css";
interface ButtonProps {
className?: string
onClick?: () => void
children?: React.ReactNode
}
export default function Button({
className,
onClick,
children,
}: ButtonProps) {
return (
<button
className={className}
onClick={onClick}
>
{children}
</button>
);
}
The component is just a passthrough for className
, onClick
, and children
.
Demo
Try out the live demo
Whats Good
Zero internal state
No extra renders, no JS hover logic, no touch hacks
Easy to theme
Override a few --button-*
variables and you’re good to go.
Variant Explosion Control
Adding .danger
or .success
is a single rule, not a refactor. There is also a clear pattern for creating new variants.
Simple markup
Doesnt’t get much simpler. Easier to integrate into pretty much anything.
Whats Not Awesome
Extra States
Extra states like selected
, loading
, danger
still need manual class or ARIA toggles.
Wrappers
To get all the animation and feature consumers of the button must use .icon-wrapper
and .text-wrapper
classes.
CSS Specificity Creep
Variant rules rely on redefining the same --button-*
props multiple times. If you later add .danger.outline
or .primary.large
, precedence chains get tricky.
Compare with Basic Buttons
Lines of Component Code
The CSS heavy button is about as small a component as it can get.
Reactivity
CSS buttons use the browser states and this feels like the correct way to handle it.
Extensibility
To extend the basic button requires creating a new button component for every variant. Using CSS classes to override the styles can lead to complex class and precedence chains, but it is also very extensible.
Themability
The css heavy button is so much easier to theme.
Overall
The two different approaches feel similar to click and use. The CSS heavy version is lighter, simpler, and scales variants better. This is the better direction for a design‑system foundation.
Next Up
Leaning on CSS and the DOM to create a React Button component worked out pretty well. Its not a perfect solution, but it is definitely a better direction than the first attempt.
Next we can look in a different direction and try a more JSX heavy approach.