Build a like button in HTML, CSS and JavaScript

A like button is a toggle: one button that is either pressed or not. Mark it up that way and the keyboard, screen readers and your CSS all read the same state.

An HTML like button is a <button> with aria-pressed. Put a heart SVG inside, keep the count in a <span> beside it, and flip aria-pressed between "false" and "true" on each click. CSS fills the heart when the attribute says true.

Try it. Click the heart, or press Tab to reach it and then Enter or Space.

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>Like button</title>
<style>
  body {
    margin: 0; min-height: 100vh; display: grid; place-items: center;
    font-family: system-ui, sans-serif; background: #f4f5f7;
  }
  .like-row { display: flex; align-items: center; gap: 8px; }
  .like {
    display: inline-grid; place-items: center;
    width: 48px; height: 48px; border-radius: 50%;
    border: 1px solid #d5d9e0; background: #fff; color: #6b7280;
    cursor: pointer;
  }
  .like svg { width: 26px; height: 26px; fill: none; stroke: currentColor; stroke-width: 2; }
  /* the ARIA state is the only source of truth for the look */
  .like[aria-pressed="true"] { color: #e11d48; border-color: #fecdd3; background: #fff1f2; }
  .like[aria-pressed="true"] svg { fill: currentColor; }
  .like:focus-visible { outline: 3px solid #2563eb; outline-offset: 2px; }
  .count { font-size: 18px; font-variant-numeric: tabular-nums; min-width: 2ch; }
</style>
</head>
<body>
<div class="like-row">
  <button class="like" id="like" type="button" aria-pressed="false" aria-label="Like">
    <svg viewBox="0 0 24 24" aria-hidden="true">
      <path d="M12 20.5s-7.5-4.6-9.3-9.4C1.4 7.7 3.6 4.5 7 4.5c2 0 3.5 1.1 5 3 1.5-1.9 3-3 5-3 3.4 0 5.6 3.2 4.3 6.6C19.5 15.9 12 20.5 12 20.5z"/>
    </svg>
  </button>
  <span class="count" id="count">12</span>
</div>

<script>
  const like = document.getElementById('like');
  const count = document.getElementById('count');

  like.addEventListener('click', () => {
    const liked = like.getAttribute('aria-pressed') === 'true';
    like.setAttribute('aria-pressed', String(!liked));  // flip the state
    count.textContent = Number(count.textContent) + (liked ? -1 : 1);
  });
</script>
</body>
</html>
A button, an SVG heart and a count. The only state is the aria-pressed attribute.

That is the whole idea: about ten lines of JavaScript. The rest of this guide covers what makes it hold up for real use: the keyboard, screen readers, the animation, and saving the like to a server.

The markup: a toggle button

The button carries three attributes, and each one has a job.

<button class="like" type="button"
        aria-pressed="false" aria-label="Like">
  <svg viewBox="0 0 24 24" aria-hidden="true">
    <path d="M12 20.5s-7.5-4.6-9.3-9.4C1.4 7.7 ..."/>
  </svg>
</button>
<span class="count">12</span>
Only aria-pressed changes between the two states. The name, the icon and the count element stay the same.
Only aria-pressed changes between the two states. The name, the icon and the count element stay the same.
  1. type="button" stops the button from submitting a form it happens to sit in. Inside a form, a button with no type is a submit button.
  2. aria-pressed turns an ordinary button into a toggle button. Screen readers announce it together with its pressed or not pressed state.
  3. aria-label="Like" gives the button a name, because an icon has no text. The aria-label guide covers when a label is needed.

The SVG gets aria-hidden="true" so it is not read as a separate image. The count lives outside the button. An aria-label replaces the text inside a button for screen readers, so a count placed inside would not be read.

Let the attribute drive the look

Do not keep a separate liked class or variable next to the attribute. If the two ever disagree, the screen shows one thing and a screen reader announces another. Style straight from the attribute instead:

.like svg { fill: none; stroke: currentColor; }
.like[aria-pressed="true"] { color: #e11d48; }
.like[aria-pressed="true"] svg { fill: currentColor; }

The heart outline uses stroke, and the pressed state adds a fill. With currentColor, one color value paints both. The SVG icons guide explains currentColor in more detail.

The click listener then reads the attribute, writes the opposite value, and moves the count by one:

like.addEventListener('click', () => {
  const liked = like.getAttribute('aria-pressed') === 'true';
  like.setAttribute('aria-pressed', String(!liked));
  count.textContent = Number(count.textContent) + (liked ? -1 : 1);
});

Why a div with onclick is not enough

A heart drawn in a <div> with a click listener works with a mouse, and nothing else. Compare the two with the keyboard: click inside the example, then press Tab.

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>div vs button</title>
<style>
  body {
    margin: 0; min-height: 100vh; font-family: system-ui, sans-serif;
    background: #f4f5f7; display: grid; grid-template-columns: 1fr 1fr;
  }
  .lane { padding: 14px; display: flex; flex-direction: column; align-items: center; gap: 10px; }
  .lane + .lane { border-left: 1px dashed #c9cdd4; }
  h3 { margin: 0; font-size: 14px; }
  p { margin: 0; font-size: 12px; color: #5b6270; text-align: center; }
  .like {
    display: inline-grid; place-items: center; width: 48px; height: 48px;
    border-radius: 50%; border: 1px solid #d5d9e0; background: #fff;
    color: #6b7280; cursor: pointer;
  }
  .like svg { width: 26px; height: 26px; fill: none; stroke: currentColor; stroke-width: 2; }
  .like.on, .like[aria-pressed="true"] { color: #e11d48; background: #fff1f2; }
  .like.on svg, .like[aria-pressed="true"] svg { fill: currentColor; }
  .like:focus-visible { outline: 3px solid #2563eb; outline-offset: 2px; }
  .log { font: 12px ui-monospace, Consolas, monospace; color: #374151; min-height: 1.4em; }
</style>
</head>
<body>
<div class="lane">
  <h3>&lt;div onclick&gt;</h3>
  <p>Press Tab: focus skips it.<br>Enter and Space do nothing.</p>
  <div class="like" id="d">
    <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 20.5s-7.5-4.6-9.3-9.4C1.4 7.7 3.6 4.5 7 4.5c2 0 3.5 1.1 5 3 1.5-1.9 3-3 5-3 3.4 0 5.6 3.2 4.3 6.6C19.5 15.9 12 20.5 12 20.5z"/></svg>
  </div>
  <div class="log" id="dlog">clicks: 0</div>
</div>
<div class="lane">
  <h3>&lt;button aria-pressed&gt;</h3>
  <p>Tab reaches it.<br>Enter and Space both toggle.</p>
  <button class="like" id="b" type="button" aria-pressed="false" aria-label="Like">
    <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 20.5s-7.5-4.6-9.3-9.4C1.4 7.7 3.6 4.5 7 4.5c2 0 3.5 1.1 5 3 1.5-1.9 3-3 5-3 3.4 0 5.6 3.2 4.3 6.6C19.5 15.9 12 20.5 12 20.5z"/></svg>
  </button>
  <div class="log" id="blog">clicks: 0</div>
</div>

<script>
  let dn = 0, bn = 0;
  const d = document.getElementById('d'), b = document.getElementById('b');

  // the div only knows about the mouse
  d.addEventListener('click', () => {
    d.classList.toggle('on');
    document.getElementById('dlog').textContent = 'clicks: ' + (++dn);
  });

  // the button also gets click from Enter and Space
  b.addEventListener('click', () => {
    b.setAttribute('aria-pressed', String(b.getAttribute('aria-pressed') !== 'true'));
    document.getElementById('blog').textContent = 'clicks: ' + (++bn);
  });
</script>
</body>
</html>
Left: a div with a click listener. Right: a button with aria-pressed. Only the right one answers Tab, Enter and Space.
<div onclick> <button aria-pressed>
Reached with Tab No Yes
Enter and Space Do nothing Fire click
Announced as Nothing or plain text Toggle button, pressed or not
Focus ring Must be added :focus-visible works

Adding tabindex="0" to the div makes it focusable, but Enter and Space still do nothing until you write a key handler. The button does all of that already. The tabindex guide explains the difference.

Add a pop animation

A short scale-up when the heart fills tells people the tap landed. Define it once with CSS keyframes and run it by adding a class:

.like.pop svg { animation: pop .35s ease-out; }
@keyframes pop {
  40% { transform: scale(1.35); }
}

An animation added by a class plays once. Removing the class and adding it back in the same moment does not replay it, because the browser never sees the class missing. Read a layout value in between, and the next click plays it again:

btn.classList.remove('pop');
void btn.offsetWidth;  // forces a style update
btn.classList.add('pop');

Respect reduced motion

Some people turn on a "reduce motion" setting in their operating system because movement on screen makes them unwell. The prefers-reduced-motion media query reports that setting to CSS.

With reduce motion on, the heart still fills and the count still changes. Only the pop is gone.
With reduce motion on, the heart still fills and the count still changes. Only the pop is gone.
@media (prefers-reduced-motion: reduce) {
  .like.pop svg { animation: none; }
}

Keep the colour change. The state must still be visible; only the movement goes. The replay trick above still works, because it only adds and removes a class.

Save it with an optimistic update

A real like is sent to a server, and the round trip takes time. If the heart waits for the answer, the tap looks ignored.

An optimistic update changes the screen at once and sends the request in the background. If the request fails, it puts the old state back.

Top: the screen waits for the server. Bottom: the screen changes first and rolls back only on failure.
Top: the screen waits for the server. Bottom: the screen changes first and rolls back only on failure.

The finished example below uses a fake server that answers after 600 ms. Tick the box to make it fail, then click a heart and watch it roll back.

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>Like button with optimistic update</title>
<style>
  body { margin: 0; padding: 14px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; }
  .opts { font-size: 13px; margin-bottom: 10px; display: flex; gap: 6px; align-items: center; }
  .post {
    display: flex; align-items: center; gap: 12px; margin-bottom: 10px;
    padding: 12px 14px; border-radius: 12px; background: #fff;
    box-shadow: 0 2px 10px rgba(0, 0, 0, .08);
  }
  .post .text { flex: 1; font-size: 14px; }
  .like-row { display: flex; align-items: center; gap: 6px; }
  .like {
    display: inline-grid; place-items: center; width: 44px; height: 44px;
    border-radius: 50%; border: 1px solid #d5d9e0; background: #fff;
    color: #6b7280; cursor: pointer;
    touch-action: manipulation;  /* no double-tap zoom on phones */
  }
  .like svg { width: 24px; height: 24px; fill: none; stroke: currentColor; stroke-width: 2; }
  .like[aria-pressed="true"] { color: #e11d48; border-color: #fecdd3; background: #fff1f2; }
  .like[aria-pressed="true"] svg { fill: currentColor; }
  .like:focus-visible { outline: 3px solid #2563eb; outline-offset: 2px; }
  .count { min-width: 3ch; font-variant-numeric: tabular-nums; }
  .like.pop svg { animation: pop .35s ease-out; }
  @keyframes pop {
    0% { transform: scale(1); }
    40% { transform: scale(1.35); }
    100% { transform: scale(1); }
  }
  /* people who turned on "reduce motion" get the colour change only */
  @media (prefers-reduced-motion: reduce) {
    .like.pop svg { animation: none; }
  }
  #status { font-size: 13px; min-height: 1.4em; color: #9a3412; }
</style>
</head>
<body>
<label class="opts"><input type="checkbox" id="fail"> Make the fake server fail</label>

<div class="post">
  <div class="text">Sunset over the harbour</div>
  <div class="like-row">
    <button class="like" type="button" aria-pressed="false" aria-label="Like" data-id="p1">
      <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 20.5s-7.5-4.6-9.3-9.4C1.4 7.7 3.6 4.5 7 4.5c2 0 3.5 1.1 5 3 1.5-1.9 3-3 5-3 3.4 0 5.6 3.2 4.3 6.6C19.5 15.9 12 20.5 12 20.5z"/></svg>
    </button>
    <span class="count">41</span>
  </div>
</div>
<div class="post">
  <div class="text">Notes from the design review</div>
  <div class="like-row">
    <button class="like" type="button" aria-pressed="true" aria-label="Like" data-id="p2">
      <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 20.5s-7.5-4.6-9.3-9.4C1.4 7.7 3.6 4.5 7 4.5c2 0 3.5 1.1 5 3 1.5-1.9 3-3 5-3 3.4 0 5.6 3.2 4.3 6.6C19.5 15.9 12 20.5 12 20.5z"/></svg>
    </button>
    <span class="count">8</span>
  </div>
</div>

<p id="status" role="status"></p>

<script>
  // Fake server: answers after 600 ms, fails when the box is ticked.
  function saveLike(id, liked) {
    const fail = document.getElementById('fail').checked;
    return new Promise((resolve, reject) =>
      setTimeout(() => (fail ? reject(new Error('offline')) : resolve()), 600));
  }

  const status = document.getElementById('status');

  function show(btn, liked, n) {
    btn.setAttribute('aria-pressed', String(liked));
    btn.nextElementSibling.textContent = n;
  }

  document.querySelectorAll('.like').forEach((btn) => {
    // last state the server confirmed, used to roll back
    let saved = {
      liked: btn.getAttribute('aria-pressed') === 'true',
      n: Number(btn.nextElementSibling.textContent),
    };
    let version = 0;  // counts clicks, so only the latest failure rolls back

    btn.addEventListener('click', async () => {
      const liked = btn.getAttribute('aria-pressed') !== 'true';
      const n = Number(btn.nextElementSibling.textContent) + (liked ? 1 : -1);

      // 1. Update the screen at once
      show(btn, liked, n);
      btn.classList.remove('pop');
      void btn.offsetWidth;  // restart the animation
      if (liked) btn.classList.add('pop');
      status.textContent = '';

      // 2. Tell the server in the background
      const mine = ++version;
      try {
        await saveLike(btn.dataset.id, liked);
        saved = { liked, n };
      } catch (err) {
        if (mine !== version) return;  // a newer click is still on its way
        show(btn, saved.liked, saved.n);  // 3. Put it back
        status.textContent = 'Could not save your like. Try again.';
      }
    });
  });
</script>
</body>
</html>
Two posts, a pop animation, reduced motion, and a fake server that can fail. Nothing leaves your browser.

Three details make it hold up:

  • Remember the last saved state. Roll back to what the server last confirmed, not just to the state before this click.
  • Only the latest click rolls back. A counter per button lets an older failed request see that a newer click has taken over.
  • Say what happened. The message sits in an element with role="status", so screen readers announce it without moving focus.

With a real server, only the saveLike function changes. Async and await explains the try and catch around it.

async function saveLike(id, liked) {
  const res = await fetch('/api/posts/' + id + '/like', {
    method: liked ? 'POST' : 'DELETE',
  });
  if (!res.ok) throw new Error(res.status);
}

A like button records one choice per post. For a score out of five, see the star rating guide, which builds on radio buttons instead.

When it does not work

What you see Cause Fix
Clicking the heart reloads the page The button is inside a form and has no type Add type="button"
Tab skips the heart It is a div or span Use a <button>
The heart never fills The <path> has its own fill="none", which beats the fill it inherits from the SVG Remove the attribute from the path
The heart looks filled but a screen reader says not pressed A class changed, the attribute did not Style from aria-pressed only
The pop plays only the first time The class was never removed Remove it, read offsetWidth, add it
After fast clicks and a failure, the heart shows a state the server never saved Each failed request rolls back to the state before its own click Roll back only on the latest click, to the last saved state
A double tap zooms the page on a phone Double-tap zoom is on for the button touch-action: manipulation on the button

A like button is something people want to press, and a screenshot cannot be pressed. An .html attachment may open as plain code, or not at all, on a phone.

To send the 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 tap the hearts themselves. If you change the code later, the same link shows the new version.

Questions people ask

Should a like button be a button or a checkbox?

Either can hold an on or off state, but a like usually takes effect at once instead of waiting for a form to be submitted. A button with aria-pressed fits that: screen readers announce it as a toggle button, and a click changes it straight away.

Should the label change from Like to Unlike?

Not when you use aria-pressed. The pressed state already tells people whether it is on, so a label that also changes would be announced as "Unlike, pressed", which reads like a double negative. Keep the name fixed and flip only aria-pressed.

Where should the like count go?

Next to the button, as ordinary text. If the button has aria-label="Like", any text inside it is replaced by that label for screen readers, so a count placed inside would not be announced. Outside the button it is read like any other text.

How do I save likes for real?

Send the new state to your server when the button is clicked, for example with fetch and a POST or DELETE request, and have the server store one like per user per post. The screen can update first and roll back if the request fails.

Keep reading