Tags
The Astro rocket logo with a pink flame and the astro wordmark in white on a black card

Astro share buttons that pick their targets by language — a sticky rail on desktop, pills on mobile

Share buttons in Astro built from intent URLs: a different target set per locale, a sticky icon rail on wide screens, and the endpoint drift found on the way.

On this page

Introduction

Every technical blog I read ends an article with a row of share buttons. This one ended with nothing, and I wanted the site to read as a finished product rather than a personal one with pieces missing. The obvious way to fix that is the official Facebook, X, and LinkedIn buttons, which is also the way that ships 200 to 500 KB of third-party JavaScript and sets tracking cookies on every reader who never touches them. The answer was intent URLs, and a different set of them in each language.

This article covers what that looks like in Astro: one registry driving two presentations, the endpoint drift that made half the tutorials online wrong, and the three defects that passed every automated check I had.

Intent URLs instead of an SDK

An intent URL is a plain link to a service’s own composer with the fields prefilled: https://x.com/intent/tweet?text=…&url=…. There is no script to load, no cookie to set, and no request is made until somebody actually follows the link.

That matters more than it first looks, because almost nobody follows it. Measured click rates on share buttons run 0.2 to 0.5%, and one widely cited case saw about fifteen clicks a month against 1.5 million visitors. A vendor SDK bills all 1.5 million for a control that fifteen people used.

A vendor share SDK bills every reader; an intent URL bills only the one who clicksTwo columns compared at page load, before any click. The official share SDK column lists 200 to 500 KB of JavaScript, third-party hosts contacted, tracking cookies set, and a cost paid by every reader. The intent URL column lists zero kilobytes, no request made, no cookie set, and a cost paid only on a click. The verdicts read that the SDK is billed to readers who never share, while the intent URL is billed to the reader who does.Page load, before anyone has clickedOfficial share SDK200–500 KB of JavaScriptThird-party hosts contactedTracking cookies setPaid by every readerBilled to readers who never shareIntent URL0 KBNo request madeNo cookie setPaid only on a clickBilled to the reader who does
The cost is paid at page load, by everyone, before the decision to share has been made.

So the design constraint was cost rather than prominence: sharing had to be free for the reader who ignores it.

The first placement was wrong

The agent’s first proposal was to pin the share icons to the foot of the existing table-of-contents rail, on the grounds that it was already sticky and already in the right gutter, so it would cost no new column and no new breakpoint.

I rejected it, because a long table of contents kills it. The sidebar is overflow-y: auto with max-height: calc(100vh - var(--nav-height)), so on an article with thirty headings the list fills the container completely. Anything pinned underneath either scrolls out of reach or eats the list it was pinned below.

What I wanted instead was the pattern Zenn, Qiita, and dev.to all use: a sticky rail in the left gutter, icon-only, with a tooltip. The agent pushed back on it twice, and both objections were fair. Their rails exist to carry a counter, a like or bookmark total that changes as you read and earns its permanent column; a share rail has no state. And an icon-only rail leans on hover, which does not exist on touch.

Both objections dissolved against things it did not know. I am adding like and bookmark buttons later, so the column will have a counter to carry. And mobile does not get the rail at all: it gets the labelled row, with the text rendered, precisely because there is no tooltip there.

The labelled share row appears at every width; the icon rail is added only from 80remTwo panels. The narrow panel, below 80rem, shows a single article column with a labelled share row beneath it, noted as working with no pointer and no JavaScript. The wide panel, 80rem and up, shows the same article column with a narrow icon rail added in the left gutter, 44 pixels clear of the text, and the same labelled share row still beneath the article.The rail is a shortcut; the row is always thereBelow 80remArticleWorks with no pointer and no JavaScript80rem and upArticle44px clear of the textLabelled row, at the foot of the articleIcon rail, tooltip on hover or focus
The row is in both panels. The rail is layered on where there is gutter to hold it, and never carries anything the row does not.

The breakpoint came out of measuring rather than guessing. The content block is a fixed 1000px, so the free gutter is 76px at 72rem, 140px at 1280px, and 220px at 1440px. At 72rem, where the table of contents appears, a rail would sit against the viewport edge with its tooltip opening over the text. The rail waits for 80rem, which puts it 44px clear.

Every endpoint had moved

The agent verified each endpoint against its own documentation rather than against a tutorial, which turned out to matter for five of the six.

Service What most guides say What actually works
X x.com/intent/post x.com/intent/tweet. The post path opens the in-app login instead of the composer
LinkedIn shareArticle with title and summary sharing/share-offsite/?url=. The old path is deprecated and its text parameters are ignored
Threads threads.net/intent/post threads.com/intent/post. Meta moved the domain
Bluesky text and url parameters text only, capped at 300 grapheme clusters
Hatena a query string b.hatena.ne.jp/entry/s/<host><path>, with the scheme folded into the /s/ prefix
Facebook sharer/sharer.php?u= unchanged

Two of those have teeth beyond a broken link.

Bluesky takes one text field and no separate URL, so a long title pushes the link itself past the 300-grapheme cap and produces a post with no link in it. The URL is the part worth posting, so the title is what yields:

src/data/share-targets.ts
function blueskyText(url: URL, title: string) {
const suffix = `\n${url.href}`;
const budget = BLUESKY_MAX_GRAPHEMES - graphemes(suffix).length;
const titleGraphemes = graphemes(title);
if (titleGraphemes.length <= budget) return `${title}${suffix}`;
return `${titleGraphemes.slice(0, budget - 1).join('')}…${suffix}`;
}

Hatena is worse, because it fails silently. It keys a bookmark entry on the literal URL string, so /blog/foo and /blog/foo/ become two separate entries with permanently split bookmark counts. This site builds to /blog/foo/, but astro dev runs with trailingSlash: 'ignore' and reports whichever form the browser asked for, so the bug is invisible in development. The URL is normalised once, in the layout, and every target derives from that one object:

src/layouts/ArticleLayout.astro
const shareUrl = new URL(
Astro.url.pathname.endsWith('/') ? Astro.url.pathname : `${Astro.url.pathname}/`,
Astro.site,
);

A target set per language

This is the part copying Zenn would not have produced.

はてなブックマーク is where a Japanese technical article actually circulates, and it has no equivalent anywhere else. Facebook still carries link sharing in Taiwan in a way it no longer does for an English audience. Showing all six networks to everybody would put four irrelevant marks in front of every reader, so the registry maps each locale to its own list:

src/data/share-targets.ts
export const SHARE_TARGETS_BY_LOCALE = {
'en': ['x', 'bluesky', 'hackernews'],
'ja': ['x', 'hatena', 'bluesky'],
'zh-tw': ['x', 'facebook', 'threads'],
} as const satisfies Record<Locale, readonly ShareTargetId[]>;

The satisfies is load-bearing. Without it, a fourth locale added to LOCALES and forgotten here renders an empty row while pnpm check stays green.

The English set has Hacker News in it rather than LinkedIn, and that was forced by an unexpected constraint. simple-icons removed the LinkedIn mark in v14.0.0 over a trademark objection and now auto-closes requests to restore it, because LinkedIn’s brand policy does not permit third parties to reproduce the logo. A pill with no glyph survives in the labelled row, where the word carries it. The rail is icon-only, and a generic briefcase there identifies nothing. I chose to swap the target rather than ship a mark we should not be shipping, and for a blog about Astro and TypeScript, Hacker News was the better destination anyway.

The icons that rendered as nothing

The marks come from simple-icons, which was already a devDependency here for the code-block file-type icons. The agent’s first pass hardcoded the path data, then found the existing registry and rewrote it to import from the package.

That rewrite introduced a bug that nothing caught. The site’s own Icon.astro stores whole <path> elements, so it renders them with set:html. simple-icons exports the bare d attribute instead. The agent carried the set:html idiom across:

<!-- injects the `d` string as text, and draws nothing -->
<svg viewBox="0 0 24 24" set:html={target.path} />
<!-- what it needed to be -->
<svg viewBox="0 0 24 24"><path d={target.path} /></svg>

astro check reported 0 errors, 0 warnings, and 0 hints on a share row whose every icon was invisible. tsc was clean, the build succeeded, and the markup was valid: an <svg> containing a text node is legal, it just paints nothing. The bug surfaced when the rendered page was screenshotted and looked at.

The tooltip behind the code block

I found the next one myself, reading a published article: the tooltip opened underneath the code block next to it.

Tree order was not the cause. Expressive Code gives every code-block header an explicit z-index: 1, and a positive z-index beats auto regardless of which element came first. The rail needed a positive value of its own, low enough to stay under the sticky header.

The share rail sits above code blocks and below the sticky headerFive layers stacked by z-index, highest at the top. Expressive Code’s copy confirmation is at 99, the skip link at 20, the sticky header and reading progress bar at 10, the newly added share rail at 2, and Expressive Code’s code-block header at 1. The rail is marked as added, and the code-block header is marked as what the tooltip was previously painted behind.Paint order on an article page99Expressive Code copy confirmation20Skip link10Sticky header and reading progress2Share rail1Expressive Code block headeraddedwhat the tooltip was hiding behind
Reading the existing ladder out of the built CSS is what picked the value. Expressive Code’s own copy confirmation sits at 99, above even the skip link.

One detail from this repo’s own history mattered here too. --nav-height sizes the bar inside the header, and the header adds its own 1px bottom border, which the reading-progress bar occupies. A sticky element offset by a bare var(--nav-height) tucks under it, so the rail uses calc(var(--nav-height) + 1.5rem).

The copy button next door was English

Adding a second copy control is what surfaced the first one being broken.

The agent found this one while checking the repo before the share work started. Expressive Code renders a copy button on every code block. Its frames plugin ships translations for English and German only, and nothing in the config told it which language a page was in. Every Japanese and Traditional Chinese article had been labelling that button Copy to clipboard and answering Copied! since the day Expressive Code landed.

The fix is a locale derived from the filename, plus the strings registered from the site’s own UI dictionary so the two copy buttons cannot drift apart:

astro.config.mjs
for (const locale of LOCALES) {
pluginFramesTexts.addLocale(locale, {
terminalWindowFallbackTitle: UI.terminalWindow[locale],
// `copyCode`, not `copyLink` — this button copies the snippet, and the
// share row's is the one that copies a URL.
copyButtonTooltip: UI.copyCode[locale],
copyButtonCopied: UI.copied[locale],
});
}
getBlockLocale: ({ file }) => file.path.match(LOCALE_FILENAME)?.[1] ?? DEFAULT_LOCALE,

That comment records the third defect. The agent’s first version reused the copyLink string for consistency between the two buttons, which meant Japanese readers saw リンクをコピー on a button that copies a snippet, not a URL. The consistency argument was right for the confirmation and wrong for the tooltip, and a code review caught it before merge. addLocale also replaces a locale’s texts wholesale, so all three keys have to be supplied; omitting the terminal-window title leaves that one string English on every terminal frame.

Summary

  • Intent URLs cost nothing until they are clicked. For a control that 0.2 to 0.5% of readers touch, that is the argument against the vendor SDKs.
  • Verify every endpoint against its own docs. Five of six had drifted, and X’s widely-copied intent/post path opens a login screen.
  • The right targets are not the same in every language. はてなブックマーク has no equivalent elsewhere, and a universal row would show every reader four marks they do not use.
  • A green type check is not a rendered page. astro check reported 0 errors, 0 warnings, and 0 hints on a row of invisible icons.
  • Trademark policy is a real constraint on which icons you can ship, and simple-icons removing a mark is the signal that you cannot.

References

Share this article