Blocks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, Copy, Download, Monitor, MonitorSmartphone, Smartphone, TriangleAlert } from "lucide-react"
import { cn } from "@/lib/utils"

/* ------------------------------------------------------------- QR ENCODER */
/* The block encodes its own smart link so a marketing page does not have to  */
/* ship a QR library, and so the symbol exists on the very first paint (a     */
/* library that resolves in an effect leaves a hole under the caption). Byte  */
/* mode only: a lowercase URL is not alphanumeric-mode eligible anyway.       */
/* -- qr-encoder-start -- */

/** Error correction level. Higher survives a dirtier print at the cost of density. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/app-download.json

Prompt

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 useSyncExternalStore snapshot whose server value is defaultPlatform and 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-foreground for bg-foreground/text-background instead 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, never Date.now(), so the block renders identically on the server, in the browser and in a screenshot taken next month.

On This Page