App Download
A get-the-app block that promotes the store matching the visitor's device, encodes the smart link into a scannable QR for desktop visitors, and renders the store badges you supply instead of redrawing brand marks.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/app-download.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "AppDownload" block with lucide-react and
cn() (clsx + tailwind-merge), no other dependency. This is the "get the app"
section of a product site: the store tiles, a QR code for the visitor sitting
at a desk, and the version/size line an app store would print. Its job is to
get the right build in front of the right device without ever hiding the wrong
one, and to do it without redrawing anybody's brand mark.
Contract
- export const AppDownload = React.forwardRef<HTMLElement, AppDownloadProps>,
root <section>, rest props spread on it, consumer className merged with cn().
AppDownloadProps extends Omit<React.HTMLAttributes<HTMLElement>, "title" |
"children"> so `title` is the block's heading, not a browser tooltip.
- AppDownloadStore = { id; name ("App Store"); platforms: DevicePlatform[];
href; badge?: React.ReactNode; requirement? ("Requires iOS 16"); version?;
sizeBytes?; updatedAt? (ISO); unavailable? }.
* badge is YOUR artwork — an <img>, an inline <svg>, a next/image. The block
lays it out and never draws an Apple or Play mark itself: both companies
forbid redrawing them, and a tokenised imitation is the wrong answer to a
trademark. With no badge, fall back to a neutral tile: a device icon
(Smartphone / Monitor / MonitorSmartphone, chosen from `platforms`) plus the
store's name.
* unavailable present = the store is listed but cannot be installed yet, and
the string is the reason ("Rolling out from 12 August").
- DevicePlatform = "ios" | "android" | "macos" | "windows" | "linux" |
"unknown". Export isHandheld(platform) = ios | android — the devices for
which a QR code is theatre.
- Block props: title; description?; eyebrow?; stores: AppDownloadStore[];
smartLink (the one URL that redirects per device — this is what the QR
encodes); qrVisibility?: "desktop" | "always" | "never" (default "desktop");
qrEcc?: "L"|"M"|"Q"|"H" (default "M"); qrCaption?; platform? (controlled,
turns detection off); defaultPlatform? ("unknown", the first-paint value);
version?/sizeBytes?/updatedAt? (fallbacks for stores that carry none); asOf?
(injected "now"); locale? ("en-US"); timeZone? ("UTC"); footnote?;
onStoreSelect?(store) and onCopyLink?(link).
- onStoreSelect is analytics only. The tiles are real <a href> elements, so
middle-click, cmd-click and "copy link address" all behave; never intercept
navigation to re-implement it.
- Export the pure helpers so the rest of the app cannot grow a second opinion:
detectPlatform(navigatorLike), formatAppSize(bytes), formatUpdated(updatedAt,
asOf, formatter), encodeQr(text, ecc), qrPathData(matrix, quietZone).
Behavior — detection promotes, it never hides
- The promoted store is the FIRST store whose `platforms` include the visitor's
platform; source order breaks ties. Every other store stays visible under an
"Also available on" row, one tab stop away, because sniffing is a guess and a
wrong guess must not cost anyone their download.
- An `unavailable` store still wins the promoted slot when it matches: hiding
the build somebody is waiting for is worse than telling them it is not ready.
- Detection succeeded but nothing matched (a Linux visitor, an iOS-only app):
no promotion, all stores at equal weight, and one honest sentence — "There is
no Linux build yet. Here is where the app does run."
- platform "unknown" (the server, a crawler, a stripped user agent) renders the
same neutral grid with no sentence: not knowing is not the same as not having.
- A "Not on X? Show every platform" button lets the visitor overrule the guess.
It only renders when there IS another platform to show, it flips the block to
the neutral grid permanently (the visitor outranks the sniffer, even if the
host later changes `platform`), and because the button unmounts with the
promoted slot it must hand focus to the first tile of the list it revealed.
- zero stores is a first-class branch: a dashed empty panel that still lets the
QR and the copy path work.
Behavior — the user agent is an external system
- Read it with useSyncExternalStore: getServerSnapshot returns defaultPlatform,
getSnapshot returns detectPlatform(navigator), subscribe is a no-op because
the user agent does not change under a mounted page. The server and hydration
therefore draw the same markup and React swaps the sniffed value in on the
render right after. Never sniff during render (SSR has no navigator) and do
not do it with useEffect + setState (that is a cascading render for the same
result, and the lint rule is right).
- detectPlatform order matters and each line is a bug someone has shipped:
iPhone/iPad/iPod in the UA, or userAgentData.platform "ios" -> ios; a
navigator.platform starting with "Mac" WITH maxTouchPoints > 1 -> ios,
because iPadOS 13+ reports itself as a Mac; Android before any Linux test,
because every Android UA contains "Linux"; then the UA-CH hints (Windows,
macOS, Linux/Chrome OS), then UA regexes, then "unknown". Take a
NavigatorLike { userAgent?; platform?; maxTouchPoints?; userAgentData? } so
it is unit-testable without a browser, and return "unknown" for null.
Behavior — the QR is generated, not fetched
- Encode the smart link in the component: byte mode, UTF-8, smallest version
1-40 that fits, Reed-Solomon ECC over GF(256) with the standard per-version
block tables, block interleaving, function patterns (finders, timing,
alignment, format info with its BCH code, version info for v7+), zig-zag
codeword placement, then all 8 masks scored by the four spec penalty rules
with the lowest score applied. Generating it synchronously means the symbol
exists in the first paint and on the server; a library that resolves in an
effect leaves a hole under the caption.
- encodeQr returns null instead of throwing for an empty string and for
anything past the version-40 ceiling (2953 bytes at level L). That is a
refusal, not a crash: the panel drops the symbol, says the link is too long
to scan, and keeps the link text and the copy button — a 3 KB campaign URL
still has to be sendable.
- Render the matrix as ONE <svg role="img" aria-label={`QR code for ${link}`}>
with a single <path> that merges horizontal runs of dark modules, plus a
4-module quiet zone in the viewBox and shapeRendering="crispEdges". One path
beats a few thousand <rect> nodes.
- qrVisibility "desktop" hides it on phones (scanning a code with the phone in
your hand is theatre), shows it for desktop AND unknown so the first paint
carries it. "always" forces it, "never" drops the whole panel.
Behavior — copy, timers and announcements
- The copy button is one-shot per burst: the guard is a ref read AND written
synchronously inside the handler, because a state flag is only visible after
a re-render and a double click lands before that. It re-arms on a ~1.6s timer
that also returns the label to "Copy"; clear that timer on unmount and before
re-arming.
- navigator.clipboard is undefined on an insecure origin and writeText can
reject: both land on a "Copy failed" state that tells the visitor to select
the link, which is on screen as text for exactly that reason. Guard the
post-await setState with an alive ref.
- One persistent <span role="status" aria-atomic class="sr-only"> announces
only things the VISITOR did: link copied, copy failed, "Showing all N
platforms", and the reason an unavailable store cannot be pressed. Detection
landing after hydration is not announced — that would read the block aloud on
every load.
Behavior — the clock is an input
- "Updated 3 days ago" comes from asOf minus updatedAt, never Date.now(), so
the same props always render the same sentence and SSR matches hydration.
Without asOf, print the absolute date instead of guessing.
- formatUpdated: unparseable input is printed VERBATIM (never "Invalid Date"),
a release in the future falls back to the absolute date, <1 day is "today",
1 day "yesterday", <30 days "N days ago", beyond that the formatted date.
- Format dates in `timeZone` (default UTC) so the server and the browser agree;
an unknown IANA zone or malformed locale makes Intl throw a RangeError at
construction, so fall back to no-zone and then to "en-US".
- formatAppSize uses DECIMAL units, because that is what the stores print:
96,400,000 bytes is "96.4 MB" — one decimal below 100, rounded above — and
negative or non-finite input returns null rather than "NaN MB".
- The meta line is assembled from what exists — "Version 4.2.1 · 96.4 MB ·
Updated 3 days ago · Requires iOS 16" — with per-store values overriding the
block-level fallbacks, and the whole line dropped when nothing is known.
Behavior — keyboard and ARIA
- Tab / Shift+Tab walk the tiles, the "show every platform" button and the copy
button; every tile is a link or a button, so Enter (and Space on buttons)
activates natively. This is a list of links, not a composite widget: no
roving tabindex, no arrow-key interception.
- An unavailable store is a <button aria-disabled="true"> with a handler guard,
NEVER the native disabled attribute: the browser blurs a focused disabled
control to <body> and the keyboard user loses their place. Because it stays
focusable, pressing it has to explain itself — announce the reason instead of
swallowing the press.
- Give every tile aria-label={store.name}, or "name — reason" when unavailable,
so a badge that is a bare <svg> without a title still has an accessible name.
Note it overrides the alt text of a badge <img>, which is the point.
- The QR panel hands focus back before it unmounts: keep a ref on the panel and
a layout effect with EMPTY dependencies whose cleanup checks
node.contains(document.activeElement) and focuses the first store tile. Empty
deps matter — a dependency change would fire that cleanup while the panel is
still on screen and steal focus for no reason. The same successor lookup
serves the "show every platform" handoff (mark the tiles with a data
attribute and take the first one in document order).
Rendering & styling
- Semantic tokens only, no hex / rgb / oklch anywhere: bg-card + border +
rounded-xl tiles and panels, hover:bg-accent hover:text-accent-foreground,
border-dashed + opacity for an unavailable tile and the empty panel,
text-muted-foreground for the meta line and captions, ring-ring focus-visible
rings with ring-offset-background.
- The QR keeps a FIXED polarity in both themes —
"bg-background text-foreground dark:bg-foreground dark:text-background" —
because plenty of camera apps refuse an inverted symbol. Dark mode swaps the
pair, not the eye.
- Layout is one flex row on sm and up: the store column flex-1 min-w-0, the QR
panel shrink-0; stacked below that. Long store names, reasons and links get
wrap-anywhere / truncate so a 90-character campaign URL cannot widen the card.
- Motion is limited to colour transitions with motion-reduce:transition-none;
nothing in this block depends on an animation running.
- Cleanup: the copy timer is cleared on unmount and before every re-arm, the
alive ref stops a resolved clipboard promise from touching an unmounted tree,
and there are no listeners or observers to leak because detection subscribes
to nothing.
Customization levers
- Sub-blocks: drop `eyebrow`, `description` or `footnote` and those lines
disappear; qrVisibility="never" removes the whole right-hand panel and leaves
a pure store row; stores={[]} leaves the QR and the copy path as a standalone
"send it to your phone" panel.
- Promotion: pass `platform` to control it from a server-side UA parse (better
than sniffing, and it kills the post-hydration swap), or set defaultPlatform
when you already know. platform="unknown" is the honest neutral grid.
- QR: qrEcc "H" survives a phone screen at an angle and a logo pasted in the
middle at the cost of density; QUIET_ZONE (4) and the svg's size-36/sm:size-40
decide how big it prints. Swap the whole encoder for your own by replacing
encodeQr — it is one pure function returning a boolean matrix.
- Copy: COPY_RESET_MS (1600) is how long the button holds its result. Wire
onCopyLink to analytics, or replace the copy row with an "email me the link"
form if that is your funnel.
- Meta line: pass version/sizeBytes/updatedAt per store when the builds differ,
or once on the block when they do not; drop asOf and the dates go absolute.
formatAppSize is decimal (store convention) — switch the divisor to 1024 if
your audience expects binary units.
- Density: the promoted tile is max-w-md and the neutral grid is
sm:grid-cols-2; raise it to 3 or 4 columns for a platform-heavy app, or make
the promoted tile full width for a single-store landing page.
- Palette: everything is monochrome tokens by design so a badge's own artwork
is the only colour in the block. If you want the promoted tile to carry the
brand, bg-primary/text-primary-foreground on that one tile is the single edit.Concepts
- Badge as a slot, never a redraw — every store tile renders the artwork you hand it and nothing else; with no badge it falls back to a device icon and the store's name. Apple and Google both forbid redrawing their marks, and a tokenised imitation is the wrong answer to a trademark question, so the block stays monochrome and lets your asset be the only colour in it.
- Detection promotes, it never hides — the matching store is lifted into the primary slot and every other store stays one tab stop away under "Also available on". Sniffing is a guess: an iPad reporting itself as a Mac, a Linux visitor, a stripped user agent. A wrong guess costs a scroll, never a download.
- Hydration-safe user agent — the platform is a
useSyncExternalStoresnapshot whose server value isdefaultPlatformand whose client value is the sniff, so the server and the browser render the same markup and React swaps in the real answer on the very next render — no effect, no cascading setState, no hydration mismatch. - Self-contained QR with a refusal — the smart link is encoded in-component (byte mode, Reed-Solomon ECC, all eight masks scored), so the symbol exists in the first paint instead of appearing after a library resolves. Past the version-40 ceiling of 2953 bytes it returns null and the panel degrades into a copy-the-link panel rather than throwing.
- Fixed polarity — dark mode swaps
bg-background/text-foregroundforbg-foreground/text-backgroundinstead of inverting the symbol, because a lot of camera apps quietly refuse a light-on-dark QR. Theming must not cost you the scan. - Deliberate successor — the "show every platform" button and the QR panel both disappear while somebody may be standing on them, so each hands focus to the first store tile before it goes: a layout-effect cleanup with empty dependencies for the panel, a pending-focus flag for the button. Focus never lands on
<body>. - The clock is an input — "Updated 3 days ago" is derived from the injected
asOf, neverDate.now(), so the block renders identically on the server, in the browser and in a screenshot taken next month.
Referral Program
A four-state referral panel: the personal invite link with copy and templated share targets, tier progress counted from the referral list itself, every referred person with their stage and reward, and the program terms behind a disclosure.
Cookie Preferences
A granular cookie consent manager: one row per category with plain-language copy and a vendor-count disclosure, a locked strictly-necessary row, equal-weight accept-all / reject-all / save-selection, and a saved-choice strip that re-opens the editor.