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

css button 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.

Previous Posts

View all posts