
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.
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 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 |
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 |
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:
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:
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:
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.
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:
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/postpath 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 checkreported 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-iconsremoving a mark is the signal that you cannot.
References
- X’s Post web intent documentation, which still gives
x.com/intent/tweetas the canonical path - Bluesky’s action intent links, including the 300 grapheme-cluster limit on the single
textparameter - Meta’s Threads web intents reference, on
threads.comrather thanthreads.net - MDN on the Web Share API, and which browsers actually implement
navigator.share - The simple-icons issue removing the LinkedIn mark in v14.0.0
- The measurements behind the 0.2 to 0.5% share-button click rate