Reactions on your blog

A blog has two natural places for reactions: inside each post's blurb on the index, where a reader is skimming, and on the post page itself, where they've just finished reading. Tapback covers both from the one loader tag. Each counter is keyed to the post, so a reaction on the index blurb and a reaction on the full post land on the same tally.

The panels below run the real embed — the same script and public demo key the home page uses on itself. Tap a reaction; it's live and shared with everyone else reading this page.

The two placements

The index page lists many posts at once, so its reactions render inline — a small bar that flows in the blurb next to each title. The post page shows one thing, so its bar floats in the bottom corner, riding along as the reader scrolls.

Both come from a single loader script in your site's <head>. It defines the <tapback-bar> element once and starts every bar on the page, however many there are:

<script type="module" async src="https://api.tapback.party/loader.js"
        data-key="pk_live_…" data-api="https://api.tapback.party"></script>

Copy your exact tag — with your real publishable key — from the Embed panel in the console. Everything else is one <tapback-bar> element per post, with two attributes deciding where and how it renders: layout (inline or float) and entity (which post it counts for).

Give each blurb its own counter

Get entity wrong here and every blurb shares a single tally. A bar's counter is its entity. Set entity and the bar counts that exact id; leave it off and the bar infers one from the page's canonical URL — host plus path.

On a post page that inference is correct: one page, one URL, one counter. On the index it collapses everything — every blurb infers the index URL, so all your posts share a single tally. So on the index you set entity explicitly on each blurb, to that post's own permalink or slug:

<!-- inside your index's post loop -->
<tapback-bar entity="/blog/hello-world/" layout="inline" live="off"></tapback-bar>

Use the same value on the post page's bar. If the index says entity="/blog/hello-world/" but the post page auto-infers, the two counters drift apart — the inferred id is lowercased and has its trailing slash stripped, which rarely matches a hand-written permalink. Pick one stable form for a post — its root-relative path, or a slug like blog/hello-world — and emit that identical expression in both templates. Your site generator already has the value; the recipes below show which variable it is.

Live preview needs JavaScript — the snippet above is the same setup.

The two blurbs above carry different entities, so they count independently. The live control switches between the running count with falling reactions (visible) and a quiet count with no socket (off) — the second is what you want on a long index, covered under keeping it fast.

The floating bar on a post page

On the post page, drop in one bar with layout="float". Where you place the element in the markup doesn't matter — a floating bar lifts out to a fixed dock in the corner, so it reads the same wherever it sits in the template:

<!-- anywhere in your single-post template -->
<tapback-bar entity="/blog/hello-world/" layout="float"></tapback-bar>

One floating bar per page. The first one claims the corner dock; a second float bar on the same page quietly renders inline instead, so keep it to one and use inline for anything else.

Live preview needs JavaScript — the snippet above is the same setup.

The post preview is keyed to docs/demo/aurora — the same id as the first blurb above. React in one panel and the other shows it, because they're the same room.

Different reactions per post

By default every post shows the same reaction set — the one on your Tapbar. A review post and a tutorial and a personal essay all get the same three or four buttons. Sometimes you want a post to react in its own vocabulary: a Halloween story with a ghost and a skull, a launch announcement with a rocket.

Set up two things and any post can carry its own set. In the console, give the Tapbar a palette — extra reactions beyond the default bar, the pool a page is allowed to pick from. Then add a reactions attribute naming the keys you want, in order:

<tapback-bar entity="/blog/the-haunting/" layout="float" reactions="ghost,skull"></tapback-bar>

The keys have to be in the approved set — the default bar plus the palette — so a page can only show reactions you've defined. Anything else in the list is skipped, and a bar with no valid keys falls back to the default bar.

The set is fixed the first time someone loads that post, keyed to its entity. Editing the attribute afterwards doesn't move a post that's already been seen — its tally has started, and quietly reshuffling the buttons under it would strand the counts readers have already left. To change a live post's reactions, use the per-page picker in the console (or the set_entity_reactions MCP tool), which re-points it and keeps the earlier counts as read-only history. New posts pick up whatever their template emits.

This is a Plus feature. On the free plan the attribute is ignored and every post shows the default bar. See the plans page.

Pick reactions where you write

The attribute suits a frontmatter workflow — the reaction list lives in the post's source. If you'd rather choose by looking at the page, there's an editor: a small picker showing every approved reaction as a chip. Tap the ones you want, in the order you want them, and save — the page is re-pointed on the spot, and the bar refreshes so you see exactly what a reader will.

It's driven by an authoring key, a separate credential minted in the console's API keys panel (Plus plan). The key can do two things: read your approved reaction set, and point a page at a subset of it. That narrow scope is why it's safe to put in a template — if it ever leaks, the worst anyone can do is rearrange pages among reactions you already approved, and you can revoke it in one click.

Paste the snippet (the console hands it to you with the key) into your CMS's draft or preview template — the one only authors see:

<script type="module" async src="https://api.tapback.party/editor.js"
        data-key="ak_live_…" data-api="https://api.tapback.party"></script>

<tapback-editor></tapback-editor>

The <tapback-editor> element is the picker, and it renders exactly where you put it — so it can sit in your editing UI's sidebar rather than on top of the post. It's a plain block element: your CSS positions it, and it picks up the --tapback-* custom properties your site already sets. If you'd rather not find it a home in your layout, <tapback-editor float> pins a button to the corner of the viewport instead.

A sidebar showing the picker usually wants the bar beside it, and there's a catch: it's a live reader bar, so an author trying it out lands in the post's real tally. Add readonly to that copy — <tapback-bar entity="blog/hello-world" layout="inline" readonly> — and it shows the current set with its live counts while ignoring taps.

In Hugo or Zola that's a template gated on the dev server or a draft flag; in WordPress, an is_preview() or admin-bar hook; in Ghost, the preview template. Keep it off your public pages — your readers would see an edit button that rejects them.

Editing UIs built in JavaScript can skip the markup: window.tapbackEditor.mount("#sidebar", { entity: "blog/hello-world" }) creates the element, drops it in, and hands it back. Naming the entity means the picker needs no bar beside it, which is what you want when the post itself renders in an iframe.

Unlike the reactions attribute, the picker can change a page that's already live: saving re-points it and keeps the earlier counts as read-only history — the same explicit re-pin as the console picker.

Try it live

Edit the elements and press Run. The loader is already wired in; change an entity, flip a layout, or paste in a second bar.

Editable preview needs JavaScript.

Add it to your site

Three moving parts, the same everywhere:

  1. The loader script, once, in the site-wide <head>.
  2. An inline bar in the index/list loop, keyed to each post's permalink, with live="off".
  3. A floating bar in the single-post template, keyed to the same permalink.

layout and live go on the <tapback-bar> element itself. The data-layout / data-live forms exist only on the loader <script> tag, as page-wide defaults.

Find your generator below. Each recipe names its index-loop template, its single-post template, and where the site-wide loader goes; use the post's permalink for entity in both templates.

Hugo

Index loop layouts/_default/list.html, single layouts/_default/single.html, per-post id .RelPermalink. Loader: add the script before </head> in your head partial (layouts/partials/head.html, or copy the theme's baseof.html). .RelPermalink is a root-relative path like /blog/hello-world/ — host-independent, so it survives a domain change.

{{/* layouts/_default/list.html — inside the range */}}
{{ range .Pages }}
  <article>
    <h2><a href="{{ .RelPermalink }}">{{ .Title }}</a></h2>
    {{ .Summary }}
    <tapback-bar entity="{{ .RelPermalink }}" layout="inline" live="off"></tapback-bar>
  </article>
{{ end }}
{{/* layouts/_default/single.html */}}
<tapback-bar entity="{{ .RelPermalink }}" layout="float"></tapback-bar>

Prefer .RelPermalink over .File.Path: .File is nil on generated pages (some taxonomy and section pages) and would error there.

Jekyll

Index loop index.html (or _layouts/home.html), single _layouts/post.html, per-post id post.url. Loader: before </head> in _includes/head.html (Minima and most themes include it from _layouts/default.html). post.url respects your permalink: config; inside _layouts/post.html the same post is page, so page.url matches.

{% for post in site.posts %}
  <article>
    <h2><a href="{{ post.url }}">{{ post.title }}</a></h2>
    {{ post.excerpt }}
    <tapback-bar entity="{{ post.url }}" layout="inline" live="off"></tapback-bar>
  </article>
{% endfor %}
<!-- _layouts/post.html -->
<tapback-bar entity="{{ page.url }}" layout="float"></tapback-bar>

Eleventy

Index loop is your list template, single _includes/layouts/post.njk, per-post id post.url. Loader: in your base layout's <head> (often _includes/layouts/base.njk). The collection name is whatever your config defines — collections.post for a tags: post setup, but the official blog starter names it collections.posts. Check yours.

{# your list template — Nunjucks #}
{% for post in collections.post %}
  <article>
    <h2><a href="{{ post.url }}">{{ post.data.title }}</a></h2>
    <p>{{ post.data.description }}</p>
    <tapback-bar entity="{{ post.url }}" layout="inline" live="off"></tapback-bar>
  </article>
{% endfor %}
{# _includes/layouts/post.njk #}
<tapback-bar entity="{{ page.url }}" layout="float"></tapback-bar>

Astro

Index src/pages/blog/index.astro, single src/pages/blog/[slug].astro, per-post id post.slug (post.id on Astro 5). Loader: in BaseLayout.astro's <head>. Add is:inline so Astro emits the tag verbatim instead of bundling it:

<script is:inline type="module" async src="https://api.tapback.party/loader.js"
        data-key="pk_live_…" data-api="https://api.tapback.party"></script>

Build the entity from the slug the same way you build the href, so the index and the [slug] route agree. On Astro 5's content layer the field is post.id; on 4 and earlier it's post.slug.

---
import { getCollection } from 'astro:content';
const posts = await getCollection('blog');
---
{posts.map((post) => (
  <article>
    <h2><a href={`/blog/${post.slug}/`}>{post.data.title}</a></h2>
    <p>{post.data.description}</p>
    <tapback-bar entity={`/blog/${post.slug}/`} layout="inline" live="off" />
  </article>
))}
{/* src/pages/blog/[slug].astro — same expression */}
<tapback-bar entity={`/blog/${post.slug}/`} layout="float" />

Next.js

Index app/blog/page.tsx, single app/blog/[slug]/page.tsx, per-post id your post.slug. App Router. Loader via next/script in the root app/layout.tsx:

import Script from 'next/script';
// inside <body>:
<Script type="module" src="https://api.tapback.party/loader.js" strategy="afterInteractive"
        data-key="pk_live_…" data-api="https://api.tapback.party" />

The type="module" matters — loader.js is an ES module, and a classic <script> throws on its import. next/script is inconsistent about module scripts across strategies, so if the bar doesn't boot, drop a plain <script type="module" async src="…/loader.js" data-key="…" data-api="…"> straight into app/layout.tsx instead.

There's no framework permalink — the id is whatever your data layer exposes, usually post.slug. Build the path once in a helper so the index and the [slug] route can't diverge.

// app/blog/page.tsx
{posts.map((post) => (
  <article key={post.slug}>
    <h2><a href={`/blog/${post.slug}`}>{post.title}</a></h2>
    <p>{post.excerpt}</p>
    {/* @ts-expect-error — custom element */}
    <tapback-bar entity={`/blog/${post.slug}`} layout="inline" live="off" />
  </article>
))}
// app/blog/[slug]/page.tsx — params is a Promise on Next.js 15+, so await it
export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  return (
    <article>
      {/* …your post… */}
      {/* @ts-expect-error — custom element */}
      <tapback-bar entity={`/blog/${slug}`} layout="float" />
    </article>
  );
}

<tapback-bar> is a custom element, so React passes the entity / layout / live attributes straight through. TypeScript needs the @ts-expect-error above, or a one-line JSX declaration for the tag. On Next.js 14 and earlier params is a plain object, so params.slug works without the await. The Pages Router is the same idea: the loader goes in pages/_app.tsx, the routes in pages/blog/index.tsx and pages/blog/[slug].tsx.

Gatsby

Index src/pages/index.js, single src/templates/blog-post.js, per-post id node.fields.slug. Loader: in gatsby-ssr.js, so it lands in every page's <head> at build:

import React from 'react';
export const onRenderBody = ({ setHeadComponents }) => {
  setHeadComponents([
    <script key="tapback" type="module" async
      src="https://api.tapback.party/loader.js"
      data-key="pk_live_…" data-api="https://api.tapback.party" />,
  ]);
};

node.fields.slug is the id your gatsby-node.js already uses for each page's path, so the index and the post template match by construction. Make sure your index query selects fields { slug }.

// src/pages/index.js
{posts.map((post) => (
  <article key={post.fields.slug}>
    <h2><Link to={post.fields.slug}>{post.frontmatter.title}</Link></h2>
    <p>{post.excerpt}</p>
    <tapback-bar entity={post.fields.slug} layout="inline" live="off" />
  </article>
))}
// src/templates/blog-post.js
<tapback-bar entity={post.fields.slug} layout="float" />

Zola

Index loop templates/section.html, single templates/page.html, per-post id page.path. This is the stack these docs run on. Loader: before </head> in templates/base.html. page.path is a root-relative path like /blog/hello-world/.

{# templates/section.html #}
{% for page in section.pages %}
  <article>
    <h2><a href="{{ page.permalink }}">{{ page.title }}</a></h2>
    {{ page.summary | safe }}
    <tapback-bar entity="{{ page.path }}" layout="inline" live="off"></tapback-bar>
  </article>
{% endfor %}
{# templates/page.html #}
<tapback-bar entity="{{ page.path }}" layout="float"></tapback-bar>

page.summary is only set when the content has a <!-- more --> marker — fall back to page.description otherwise.

Ghost

Index index.hbs, single post.hbs, per-post id {{slug}}. Loader: no theme edit needed — paste the script into Settings → Code injection → Site Header. It's emitted through {{ghost_head}} on every page. The inline bars still need template edits, since they live inside the posts loop.

{{!-- index.hbs --}}
{{#foreach posts}}
  <article>
    <h2><a href="{{url}}">{{title}}</a></h2>
    <p>{{excerpt}}</p>
    <tapback-bar entity="{{slug}}" layout="inline" live="off"></tapback-bar>
  </article>
{{/foreach}}
{{!-- post.hbs --}}
<tapback-bar entity="{{slug}}" layout="float"></tapback-bar>

{{slug}} is host-independent and simplest here. On Ghost(Pro), editing index.hbs / post.hbs means downloading the active theme, changing it, and re-uploading the zip (or using GitHub theme sync).

WordPress

Index home.php / index.php, single single.php, per-post id get_permalink(). Loader: hook wp_head from your child theme's functions.php (update-safe), or a small plugin:

add_action( 'wp_head', function () { ?>
  <script type="module" async src="https://api.tapback.party/loader.js"
    data-key="pk_live_…" data-api="https://api.tapback.party"></script>
<?php } );

Use get_permalink() (which returns the URL) in the attribute, and escape it. the_permalink() echoes instead, so it won't work inside an attribute expression.

<?php // home.php / index.php — inside The Loop
while ( have_posts() ) : the_post(); ?>
  <article>
    <h2><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></h2>
    <?php the_excerpt(); ?>
    <tapback-bar entity="<?php echo esc_attr( get_permalink() ); ?>" layout="inline" live="off"></tapback-bar>
  </article>
<?php endwhile; ?>
<?php // single.php — inside The Loop
while ( have_posts() ) : the_post(); the_content(); ?>
  <tapback-bar entity="<?php echo esc_attr( get_permalink() ); ?>" layout="float"></tapback-bar>
<?php endwhile; ?>

Block (full-site-editing) themes have no PHP Loop — the home.php / single.php files above don't exist. The wp_head loader still works, but placing an inline bar per Query-Loop item needs an HTML block, a shortcode, or a small custom block.

Keep a long index fast

An index can list dozens of posts. Two things keep that cheap:

When counts look wrong

SymptomCauseFix
Every blurb shows the same countNo entity set, so each bar infers the index page's URLSet entity explicitly on each blurb, keyed to its post
Blurb and post counts differThe two templates emit different entity values, or the post auto-infersUse the identical expression in both templates
Bar renders in the corner, not in the blurblayout="inline" is missing (or you put data-layout on the element)Put layout="inline" on the <tapback-bar> element
A second floating bar appears inlineOnly the first float bar can claim the corner dockKeep one float bar per page; use inline for the rest
No bar anywhere on the pageThe loader <script> isn't in that page's <head>Include the loader once per page, site-wide

From here, the embed reference is the full list of what the loader and each bar accept, Theming covers matching the bar to your design, and The JS API is there if you'd rather wire your own buttons.