Good-looking buttons with HTML and CSS

A good button is a real <button> element, about a dozen lines of CSS, and five states. Toggle each layer below and watch what it adds.

To make a button look good with HTML and CSS, start from a real <button> element, reset the browser's default look, then add padding, a border radius and a colour.

After that come the states: hover, pressed, keyboard focus and disabled. Each is one short rule.

Try it first. Tick the boxes one at a time and watch the button change. The CSS for each layer appears underneath.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Button CSS, one layer at a time</title>
<style>
  body { margin: 0; padding: 16px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .controls { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 14px; }
  .controls label { display: flex; align-items: center; gap: 6px; cursor: pointer; }
  .stage { display: grid; place-items: center; height: 96px; margin: 12px 0; border-radius: 12px; background: #fff; }
  pre { margin: 0; padding: 12px; border-radius: 10px; background: #1d2330; color: #e5e7eb;
        font: 12.5px/1.5 ui-monospace, Consolas, monospace; white-space: pre-wrap; min-height: 40px; }

  /* The six layers. Each one only applies while its box is ticked. */
  .reset .btn  { appearance: none; border: 0; background: none; font: inherit; }
  .pad .btn    { padding: 12px 22px; border-radius: 10px; }
  .colour .btn { background: #2563eb; color: #fff; font-weight: 600; cursor: pointer; }
  @media (hover: hover) {
    .hover .btn:hover { background: #1d4ed8; }
  }
  .active .btn:active { transform: translateY(1px) scale(.97); }
  .focus .btn:focus-visible { outline: 3px solid #93c5fd; outline-offset: 2px; }
</style>
</head>
<body>
<div class="controls" id="controls">
  <label><input type="checkbox" value="reset"> 1 Reset</label>
  <label><input type="checkbox" value="pad"> 2 Padding</label>
  <label><input type="checkbox" value="colour"> 3 Colour</label>
  <label><input type="checkbox" value="hover"> 4 Hover</label>
  <label><input type="checkbox" value="active"> 5 Pressed</label>
  <label><input type="checkbox" value="focus"> 6 Focus</label>
</div>

<div class="stage" id="stage">
  <button class="btn" type="button">Save changes</button>
</div>

<pre id="css"></pre>

<script>
  // The CSS each layer adds, shown under the button
  const rules = {
    reset:  '.btn { appearance: none; border: 0; background: none; font: inherit; }',
    pad:    '.btn { padding: 12px 22px; border-radius: 10px; }',
    colour: '.btn { background: #2563eb; color: #fff; font-weight: 600; cursor: pointer; }',
    hover:  '@media (hover: hover) { .btn:hover { background: #1d4ed8; } }',
    active: '.btn:active { transform: translateY(1px) scale(.97); }',
    focus:  '.btn:focus-visible { outline: 3px solid #93c5fd; outline-offset: 2px; }'
  };
  const stage = document.getElementById('stage');
  const css = document.getElementById('css');

  function update() {
    const on = [...document.querySelectorAll('#controls input:checked')].map(i => i.value);
    stage.className = 'stage ' + on.join(' ');
    css.textContent = on.length ? on.map(k => rules[k]).join('\n') : '/* No CSS yet: this is the browser default button. */';
  }
  document.getElementById('controls').addEventListener('change', update);
  update();
</script>
</body>
</html>
One button, six layers of CSS. Tick a box to add that layer; press Tab to see the focus ring.

With nothing ticked, you see the browser's own button: small, grey and in a different font from the page. Six short rules turn it into something you would ship.

Styling can make a link and a button look identical. They still behave differently, so choose by what the click does, not by how it should look.

A link goes somewhere. A button does something on the page.
A link goes somewhere. A button does something on the page.
  • Goes somewhere (another page, a file, a section): use <a href="...">. It can be opened in a new tab and copied.
  • Does something (save, open a menu, copy, submit): use <button type="button">, or type="submit" inside a form.

Styling a link to look like a button is covered in HTML button with link. Everything below applies to both, since the CSS is the same.

Reset the browser defaults

Browsers draw buttons with their own border, background and font. Some also apply the operating system's native control look. These three lines clear it so your styles start from nothing:

.btn {
  appearance: none;   /* drop the native control look */
  border: 0;
  font: inherit;      /* use the page font, not the browser's button font */
}

font: inherit is the easiest line to forget. Without it, the button text stays in the browser's default size and family even when the rest of the page is styled.

Padding, shape and colour

Size a button with padding, not with a fixed width and height. Padding grows with the label, so a longer word never spills out.

.btn {
  padding: 12px 22px;
  border-radius: 10px;
  background: #2563eb;
  color: #fff;
  font-weight: 600;
  cursor: pointer;
}

Keep the text and background contrast high. White on a mid or dark blue reads well; white on a pale colour does not. cursor: pointer is optional, since buttons show the default arrow unless you set it.

The states: hover, pressed, focus, disabled

A button that never reacts feels broken. Each state is one selector.

The five looks of one button. Each is a separate CSS rule.
The five looks of one button. Each is a separate CSS rule.
@media (hover: hover) {
  .btn:hover { background: #1d4ed8; }
}
.btn:active { transform: translateY(1px) scale(.97); }
.btn:focus-visible { outline: 3px solid #93c5fd; outline-offset: 2px; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
  • :hover darkens the button under a mouse. The @media (hover: hover) wrapper limits it to devices that can hover, so it does not stick after a tap on a phone.
  • :active applies while the button is held down. A one-pixel move is enough to feel like a press.
  • :focus-visible shows a ring when the browser decides focus should be visible, which covers keyboard users pressing Tab. A mouse click on a button usually does not trigger it.
  • :disabled matches buttons with the disabled attribute. They cannot be clicked or focused, so fade them clearly.

To animate the colour change instead of switching it, add a transition; CSS hover transition covers that.

Variants: primary, outline, ghost, danger

A page with several actions needs more than one kind of button. Keep one base .btn rule for size and shape, and let each variant change only colours.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Button variants</title>
<style>
  body { margin: 0; padding: 18px; font-family: system-ui, sans-serif; background: #fff; color: #1d2330; }
  .row { display: flex; flex-wrap: wrap; gap: 12px; }

  /* Shared base for every variant */
  .btn {
    appearance: none; font: inherit; font-weight: 600;
    display: inline-flex; align-items: center; justify-content: center; gap: 8px;
    min-height: 44px; padding: 10px 18px;
    border: 2px solid transparent; border-radius: 10px;
    cursor: pointer;
  }
  .btn svg { width: 18px; height: 18px; }
  .btn:focus-visible { outline: 3px solid #93c5fd; outline-offset: 2px; }
  .btn:not(:disabled):active { transform: translateY(1px); }
  .btn:disabled { opacity: .5; cursor: not-allowed; }

  /* Variants only change colours */
  .primary { background: #2563eb; color: #fff; }
  .outline { background: #fff; color: #2563eb; border-color: #2563eb; }
  .ghost   { background: transparent; color: #2563eb; }
  .danger  { background: #dc2626; color: #fff; }
  .icon    { padding: 10px; min-width: 44px; background: #f1f5f9; color: #334155; }

  /* Hover only where a mouse or trackpad can hover */
  @media (hover: hover) {
    .primary:not(:disabled):hover { background: #1d4ed8; }
    .outline:not(:disabled):hover, .ghost:not(:disabled):hover { background: #eff6ff; }
    .danger:not(:disabled):hover  { background: #b91c1c; }
    .icon:not(:disabled):hover    { background: #e2e8f0; }
  }

  #out { margin: 16px 0 0; font-size: 14px; color: #4b5563; }
</style>
</head>
<body>
<div class="row" id="row">
  <button class="btn primary" type="button">Primary</button>
  <button class="btn outline" type="button">Outline</button>
  <button class="btn ghost" type="button">Ghost</button>
  <button class="btn danger" type="button">Delete</button>
  <button class="btn primary" type="button">
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 4v11M7 10l5 5 5-5M5 20h14"/></svg>
    Download
  </button>
  <button class="btn icon" type="button" aria-label="Settings">
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h2M10 17h10"/><circle cx="16" cy="7" r="2"/><circle cx="8" cy="17" r="2"/></svg>
  </button>
  <button class="btn primary" type="button" disabled>Disabled</button>
</div>
<p id="out">Click a button, or press Tab to see the focus ring.</p>

<script>
  // Report which button was pressed. Disabled buttons never fire click.
  document.getElementById('row').addEventListener('click', (e) => {
    const btn = e.target.closest('button');
    if (btn) document.getElementById('out').textContent =
      'You clicked: ' + (btn.getAttribute('aria-label') || btn.textContent.trim());
  });
</script>
</body>
</html>
Primary, outline, ghost, danger, icon with text, icon only, and disabled. Same base class, different colours.
Variant Use it for CSS that changes
Primary The one main action on the screen Solid brand background, white text
Outline (secondary) A second choice next to the primary White background, coloured border and text
Ghost Low-key actions such as Cancel or Skip No background or border, coloured text
Danger Delete and other actions you cannot undo Solid red background
Icon only Toolbars with a familiar symbol Square padding, plus aria-label

For an icon next to text, make the button display: inline-flex with align-items: center and a gap. Draw the icon with stroke="currentColor" so it follows the text colour in every variant.

An icon-only button has no text for a screen reader to announce. Give it a name with aria-label:

<button class="btn icon" type="button" aria-label="Settings">
  <svg aria-hidden="true" ...></svg>
</button>

A pair of equal-looking choices, such as Yes and No, is covered in HTML yes no button.

Loading and success states

When a click starts something slow, the button should say so. Otherwise people click again, and the action runs twice.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Button with loading and success states</title>
<style>
  body { margin: 0; padding: 18px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  form { max-width: 420px; padding: 18px; border-radius: 12px; background: #fff; }
  label { display: block; font-size: 14px; margin-bottom: 12px; }
  input { display: block; width: 100%; box-sizing: border-box; margin-top: 6px; padding: 10px;
          font: inherit; border: 1px solid #cbd5e1; border-radius: 8px; }

  .btn {
    appearance: none; font: inherit; font-weight: 600;
    display: inline-flex; align-items: center; justify-content: center; gap: 8px;
    min-width: 160px; min-height: 44px; padding: 10px 20px;
    border: 0; border-radius: 10px; background: #2563eb; color: #fff; cursor: pointer;
    transition: background-color .2s;
  }
  @media (hover: hover) { .btn:not(:disabled):hover { background: #1d4ed8; } }
  .btn:not(:disabled):active { transform: translateY(1px); }
  .btn:focus-visible { outline: 3px solid #93c5fd; outline-offset: 2px; }
  .btn:disabled { cursor: progress; }
  .btn.done { background: #15803d; }

  /* Spinner: a ring with one coloured side, rotating */
  .spin { display: none; width: 16px; height: 16px; border-radius: 50%;
          border: 2px solid rgba(255, 255, 255, .4); border-top-color: #fff;
          animation: turn .7s linear infinite; }
  .btn.loading .spin { display: inline-block; }
  @keyframes turn { to { transform: rotate(360deg); } }
  @media (prefers-reduced-motion: reduce) { .spin { animation-duration: 2s; } }

  /* On phones, the main button fills the width */
  @media (max-width: 480px) { .btn { width: 100%; } }

  #status { min-height: 1.4em; margin: 10px 0 0; font-size: 14px; color: #15803d; }
</style>
</head>
<body>
<form id="form">
  <label>Display name <input name="name" value="Ada Lovelace"></label>
  <button class="btn" id="save" type="submit">
    <span class="spin" aria-hidden="true"></span><span class="label">Save changes</span>
  </button>
  <p id="status" role="status"></p>
</form>

<script>
  const form = document.getElementById('form');
  const btn = document.getElementById('save');
  const label = btn.querySelector('.label');
  const status = document.getElementById('status');

  form.addEventListener('submit', (e) => {
    e.preventDefault();  // no server here: the save is simulated below
    btn.disabled = true;               // blocks a second click while saving
    btn.classList.add('loading');
    label.textContent = 'Saving…';
    status.textContent = '';

    setTimeout(() => {                 // pretend the server answered after 1.5 s
      btn.classList.replace('loading', 'done');
      label.textContent = '✓ Saved';
      status.textContent = 'Saved "' + form.elements.name.value + '".';

      setTimeout(() => {               // back to normal after 2 s
        btn.classList.remove('done');
        btn.disabled = false;
        label.textContent = 'Save changes';
      }, 2000);
    }, 1500);
  });
</script>
</body>
</html>
Click Save. The button shows a spinner, then a success state, then returns to normal. The save is simulated.

The pattern has three steps:

  1. On click: set disabled so a second click does nothing, add a loading class that shows the spinner, and change the label to "Saving…".
  2. When the work finishes: swap loading for done, show a check mark and a green background.
  3. After a moment: remove done, re-enable the button and restore the label.

A min-width on the button stops it from shrinking when the label changes. The status line under it has role="status", so screen readers announce the result. The same confirm-then-reset idea drives the HTML copy button.

Sizing buttons for phones

A fingertip is much larger than a mouse pointer. Small buttons placed side by side get mis-tapped.

On a phone, taller and full-width buttons keep each tap on the button meant.
On a phone, taller and full-width buttons keep each tap on the button meant.

WCAG 2.2 asks for touch targets of at least 24 by 24 CSS pixels at level AA, and 44 by 44 at level AAA. A min-height: 44px meets both heights. On narrow screens, let the main button fill the row:

.btn { min-height: 44px; }
@media (max-width: 480px) {
  .btn { width: 100%; }
}

When it does not work

What you see Cause Fix
Your styles barely change the button The browser's default button look and font still apply appearance: none, border: 0, font: inherit
Button text is a different font or size Buttons do not inherit the page font by default font: inherit on the button
Keyboard users cannot tell which button is selected outline: none removed the focus ring with nothing in its place Add a :focus-visible outline
Clicking the button reloads the page or submits the form A <button> in a form defaults to type="submit" Add type="button"
Opening in a new tab or copying the address does not work A <button> with a script is doing a link's job Use <a href> styled as a button
On a phone, the button stays dark after a tap :hover can stay active after a tap on touch screens Wrap hover rules in @media (hover: hover)
Nothing happens on click at all Something covers the button, or it is disabled See HTML button not clickable

Button states are easier to judge by pressing them than by looking at a screenshot, which cannot show hover, the pressed effect or the loading spinner.

To send a working version, paste the page into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the people you send it to can click, tab and tap the buttons themselves. If you change the styles later, the same link shows the new version.

Questions people ask

Should I use a button or a link styled as a button?

Use a link (<a href>) when clicking takes the user to another page or address. Use a <button> when clicking does something on the current page, such as saving, opening a menu or submitting a form. The look can be the same; the element decides how browsers and screen readers treat it.

Why does my button not use the same font as the rest of the page?

Browsers give form controls, buttons included, their own font instead of inheriting it from the page. Add font: inherit to the button rule.

Is it OK to remove the outline on buttons?

Only if you put a clear focus style back. Without one, people who move through the page with the Tab key cannot see which button is selected. Use :focus-visible so the ring appears for keyboard focus without showing on every mouse click.

Why does clicking my button reload the page?

A <button> inside a <form> with no type attribute is a submit button, so clicking it submits the form. Add type="button" to buttons that are not meant to submit.

How big should a button be on a phone?

WCAG 2.2 asks for touch targets of at least 24 by 24 CSS pixels (level AA) and recommends 44 by 44 (level AAA). A min-height of 44px, and full width for the main action on narrow screens, keeps taps on the right button.

Keep reading