← ui

contacts-app

ui

Apple's iPhone Contacts app drawn from a vCard: the .vcf import sheet, the saved card

npx shadcn add @ag/contacts-app
Already installed? Preview an update
npx shadcn add @ag/contacts-app --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-15
  • New: ContactsApp, a picture of iOS 26's Contacts app drawn from a .vcf's text, for an IphoneFrame's screen: view="import" is the sheet iOS raises over an opened .vcf (pressed lights Create New Contact, the instant before it saves), view="contact" the saved card (back and Edit, the face, the round actions dimmed where the card has no channel, each kind of field its own group with its glyph at the end, the tab bar). As iOS 26 draws it: a poster in the photo's colour (average, the photo's mean, prepare-media's; iOS's grey without one), everything on it white, the controls the registry's liquid glass. scroll (0 to 1) scrolls the saved card from its top to its foot, for a story's scroll to drive. Every length is an iOS point as a share of the screen's width, so it is the same screen at any frame size. contactsAppScreen(view, average) is the frame's style: the status bar's ink and the ground under it, which the frame draws. Fields are read by contact's lib/vcard-core.
  • view="preview": the sheet iOS 26 raises over Safari for an opened .vcf, done (a blue ✓) and share in glass, the face larger, no commands under the card; it scrolls with scroll as the saved card does. Under the name, the company or else the nickname, in every view.
  • The import sheet's top corners clip with clip-path, never overflow: hidden, which kept the glass on it from blurring everything (see device-frame).
  • CONTACTS_APP_CONTROLS: where a finger presses the app's controls (the preview's done ✓), in points on the 402-point screen, for a story's Touch; done swells while pressed (data-pressing round the app), as iOS 26's glass does.
  • If you installed it earlier today: scheme is gone, the poster takes the photo's colour; pass average to both ContactsApp and contactsAppScreen.
Updated 2026-10-15 · release notes

also called ios contacts, iphone contact, vcard import

shelf devices & demos

uses contact, image, material

a profile in Safari on the phone, its save pressed; iOS 26 raises the card with done and share; done, it is in Contacts

Benjamin Franklin

printer, writer, inventor

save contact

the import sheet the instant Create New Contact is pressed, and the card it saves

three photos' averages, a pale one among them: each settles deep enough for white, a grey stays grey

top, halfway, foot: `scroll` from a story's progress

the import sheet

a long card is shown scrolled to its foot, where the two commands are, as a finger would have to

a short card

no photo: the monogram on iOS's grey; a channel the card lacks is dimmed

the demo above, verbatim

contacts-app.demo.tsx
import { readFileSync } from "node:fs"
import { join } from "node:path"
import { Sample } from "@/app/samples"
import { Browser } from "@/registry/base-nova/ui/browser"
import {
  ContactsApp,
  contactsAppScreen,
} from "@/registry/base-nova/ui/contacts-app"
import { IphoneFrame } from "@/registry/base-nova/ui/device-frame"
import { Image } from "@/registry/base-nova/ui/image"

// #region franklin
// A contact as a phone writes it: a photo by address, Apple's labelled links, an
// escape in the note.
const FRANKLIN = [
  "BEGIN:VCARD",
  "VERSION:3.0",
  "FN:Benjamin Franklin",
  "N:Franklin;Benjamin;;;",
  "NICKNAME:ben",
  "ORG:Philadelphia Post Office",
  "PHOTO;VALUE=uri:/benjamin-franklin.png",
  "item1.URL:https://e-id.to/franklin",
  "item1.X-ABLabel:e-ID",
  "item2.URL:https://en.wikipedia.org/wiki/Benjamin_Franklin",
  "item2.X-ABLabel:_$!<HomePage>!$_",
  "EMAIL;TYPE=INTERNET:ben@example.com",
  "TEL;TYPE=CELL:+1 215 555 0176",
  "ADR;TYPE=HOME:;;;Philadelphia;;;United States",
  "NOTE:Printer\\, writer\\, inventor.",
  "END:VCARD",
].join("\r\n")
// #endregion

// #region twain
// A sparse card: no photo, no phone, so message and call are dimmed.
const TWAIN = [
  "BEGIN:VCARD",
  "VERSION:4.0",
  "FN:Mark Twain",
  "EMAIL;TYPE=work:mark@example.com",
  "item1.URL:https://e-id.to/twain",
  "item1.X-ABLabel:e-ID",
  "END:VCARD",
].join("\r\n")
// #endregion

// #region average
// The poster's colour is the photo's mean, measured where its pixels are: the
// portrait's sidecar holds it (prepare-media's `average`).
const [r, g, b] = (
  JSON.parse(
    readFileSync(join(process.cwd(), "public/benjamin-franklin.json"), "utf8"),
  ) as { average: [number, number, number] }
).average
const FRANKLIN_AVERAGE = `rgb(${r} ${g} ${b})`
// #endregion

// #region profile
// A profile page as Safari shows it on the phone, with the button that serves its
// .vcf: what a person presses before iOS raises the preview.
function Profile() {
  return (
    <div className="flex min-h-full flex-col items-center gap-[4cqw] bg-[#111] px-[6cqw] py-[8cqw] text-white">
      <Image
        src="/benjamin-franklin.png"
        alt=""
        className="size-[34cqw] rounded-[5cqw] object-cover"
      />
      <p className="font-semibold text-[7cqw]">Benjamin Franklin</p>
      <p className="text-[4cqw] text-white/60">printer, writer, inventor</p>
      <span className="rounded-full border border-white/25 px-[5cqw] py-[2.4cqw] text-[4.2cqw]">
        save contact
      </span>
    </div>
  )
}
// #endregion

export default function ContactsAppDemo() {
  return (
    <div className="space-y-8">
      <Sample
        name="safari"
        title="saved from safari"
        description="a profile in Safari on the phone, its save pressed; iOS 26 raises the card with done and share; done, it is in Contacts"
        with="franklin average profile"
      >
        <div className="flex flex-wrap justify-center gap-8">
          <div className="w-52">
            <IphoneFrame>
              <Browser
                look="safari-ios"
                material="glass"
                scheme="dark"
                url="https://e-id.to/franklin"
                full
              >
                <Profile />
              </Browser>
            </IphoneFrame>
          </div>
          <div className="w-52">
            <IphoneFrame style={contactsAppScreen("preview", FRANKLIN_AVERAGE)}>
              <ContactsApp
                vcard={FRANKLIN}
                view="preview"
                average={FRANKLIN_AVERAGE}
              />
            </IphoneFrame>
          </div>
          <div className="w-52 max-sm:hidden">
            <IphoneFrame style={contactsAppScreen("contact", FRANKLIN_AVERAGE)}>
              <ContactsApp
                vcard={FRANKLIN}
                view="contact"
                average={FRANKLIN_AVERAGE}
              />
            </IphoneFrame>
          </div>
        </div>
      </Sample>
      <Sample
        name="save"
        glance="large"
        title="a .vcf opened, then saved"
        description="the import sheet the instant Create New Contact is pressed, and the card it saves"
        with="franklin average"
      >
        <div className="flex justify-center gap-8">
          <div className="w-52">
            <IphoneFrame style={contactsAppScreen("import", FRANKLIN_AVERAGE)}>
              <ContactsApp
                vcard={FRANKLIN}
                view="import"
                pressed
                average={FRANKLIN_AVERAGE}
              />
            </IphoneFrame>
          </div>
          <div className="w-52 max-sm:hidden">
            <IphoneFrame style={contactsAppScreen("contact", FRANKLIN_AVERAGE)}>
              <ContactsApp
                vcard={FRANKLIN}
                view="contact"
                average={FRANKLIN_AVERAGE}
              />
            </IphoneFrame>
          </div>
        </div>
      </Sample>
      <Sample
        name="averages"
        title="the poster in the photo's colour"
        description="three photos' averages, a pale one among them: each settles deep enough for white, a grey stays grey"
        with="franklin"
      >
        <div className="flex flex-wrap justify-center gap-10">
          {["#5b7fbf", "#d8cbb4", "#8a8a8a"].map((average) => (
            <div key={average} className="w-72">
              <IphoneFrame style={contactsAppScreen("contact", average)}>
                <ContactsApp
                  vcard={FRANKLIN}
                  view="contact"
                  average={average}
                />
              </IphoneFrame>
            </div>
          ))}
        </div>
      </Sample>
      <Sample
        name="scroll"
        title="the card scrolled"
        description="top, halfway, foot: `scroll` from a story's progress"
        with="franklin average"
      >
        <div className="flex flex-wrap justify-center gap-10">
          {[0, 0.5, 1].map((scroll) => (
            <div key={scroll} className="w-60">
              <IphoneFrame
                style={contactsAppScreen("contact", FRANKLIN_AVERAGE)}
              >
                <ContactsApp
                  vcard={FRANKLIN}
                  view="contact"
                  scroll={scroll}
                  average={FRANKLIN_AVERAGE}
                />
              </IphoneFrame>
            </div>
          ))}
        </div>
      </Sample>
      <Sample
        name="import"
        title="the import sheet"
        description="a long card is shown scrolled to its foot, where the two commands are, as a finger would have to"
        with="franklin average"
      >
        <div className="flex justify-center">
          <div className="w-80">
            <IphoneFrame style={contactsAppScreen("import", FRANKLIN_AVERAGE)}>
              <ContactsApp
                vcard={FRANKLIN}
                view="import"
                average={FRANKLIN_AVERAGE}
              />
            </IphoneFrame>
          </div>
        </div>
      </Sample>
      <Sample
        name="sparse"
        title="a short card"
        description="no photo: the monogram on iOS's grey; a channel the card lacks is dimmed"
        with="twain"
      >
        <div className="flex flex-wrap justify-center gap-10">
          <div className="w-80">
            <IphoneFrame style={contactsAppScreen("import")}>
              <ContactsApp vcard={TWAIN} view="import" />
            </IphoneFrame>
          </div>
          <div className="w-80">
            <IphoneFrame style={contactsAppScreen("contact")}>
              <ContactsApp vcard={TWAIN} view="contact" />
            </IphoneFrame>
          </div>
        </div>
      </Sample>
    </div>
  )
}

the source, every file an install writes

contacts-app.tsx
import {
  Check,
  ChevronLeft,
  CircleUser,
  Clock,
  Compass,
  Mail,
  MapPin,
  MessageCircle,
  Phone,
  Search,
  Share,
  Video,
  X,
} from "lucide-react"
import type * as React from "react"
import { cn } from "@/lib/utils"
import {
  parseVcard,
  type VCard,
  type VCardField,
} from "@/registry/base-nova/lib/vcard-core"
import { Image } from "@/registry/base-nova/ui/image"
import { glassVariants } from "@/registry/base-nova/ui/material-glass"
import "./contacts-app.css"

// APPLE'S CONTACTS APP ON AN IPHONE (iOS 26), drawn from a vCard (lib/vcard-core):
// a picture of the app, never the app. iOS 26 sets a contact on a poster in the
// colour of its photo: the screen that colour, everything on it white, the
// controls liquid glass. Three screens: `preview`, the sheet iOS 26 raises when a
// .vcf is opened from Safari (done, a blue ✓, and share in glass, the face large,
// its name and nickname, the fields; ✓ saves it); `import`, the sheet as iOS drew it
// before (the card, then Create New Contact and Add to Existing Contact; `pressed`
// lights the first, the instant before it saves); and `contact`, the saved card in
// Contacts (the back chevron and Edit in glass, the face, the name, the four glass
// actions dimmed where the card has no channel for them, each kind of field its
// own group with its glyph at the end, the tab bar at the foot). `preview` and
// `contact` scroll (`scroll`, 0 the top to 1 the foot), for a story's scroll to
// drive.
//
// The poster's colour comes from the photo's `average` (prepare-media measures it:
// a prepared picture's `average`, or `averageOf` on bytes fetched at request
// time), passed in, since a picture rendered on the server holds no pixels to read
// it from. Its hue and saturation are kept and its lightness settled where white
// reads (`settle`), so a grey photo gives a grey poster, as a quote's ground does
// (quote-card, `toneFrom`). It fills the positioned box it is put in, an
// `IphoneFrame`'s screen, and lays out in that box's own width: every length is an
// iOS point as cqw of a 402 pt screen (iPhone 16 Pro). The status bar and the
// island are the frame's; their ink and the ground under them come from
// `contactsAppScreen`. One `role="img"`, its insides hidden from assistive tech. No
// state: it renders on the server.
//
//   <IphoneFrame style={contactsAppScreen("contact", average)}>
//     <ContactsApp vcard={vcfText} view="contact" average={average} />
//   </IphoneFrame>

export type ContactsAppView = "preview" | "import" | "contact"

/** A screen that is a sheet raised over another app: black round it. */
const sheet = (view: ContactsAppView) => view !== "contact"

/** The poster's colour where none is given: iOS's own for a contact with no
 *  photo, a cool grey. */
const NO_PHOTO = "#7c8494"

/** The iPhone 16 Pro's screen width in points: every length below is a share of it. */
const SCREEN_PT = 402
const cqw = (pt: number) => `${((pt * 100) / SCREEN_PT).toFixed(3)}cqw`

/** iOS 26's metrics, in points. */
const PT = {
  // Safe areas: the status bar's band, the home indicator's.
  "safe-top": 62,
  "safe-bottom": 34,
  // Grouped rows: the side margin (also a row's text inset and the separator's),
  // a row's vertical padding, a group's corner, the gap between groups, the
  // glyph at a row's end.
  margin: 16,
  "row-y": 13,
  radius: 22,
  gap: 14,
  trail: 21,
  // A bar's glass button, a capsule or a circle this tall; its glyphs.
  bar: 48,
  "bar-x": 18,
  chevron: 26,
  close: 20,
  // The header: the photo, its monogram, the name, the line under it.
  photo: 186,
  monogram: 72,
  "photo-gap": 20,
  title: 34,
  "title-leading": 40,
  // The four glass actions: a circle, its glyph, the gap between.
  action: 62,
  icon: 26,
  "action-gap": 12,
  "head-gap": 22,
  // A row's two lines: the label, the value.
  label: 16,
  "label-leading": 21,
  value: 19,
  "value-leading": 24,
  sub: 17,
  "sub-leading": 22,
  // The tab bar: its height, how far it stands off the foot and the sides, the
  // search circle beside it, a tab's glyph and word.
  tab: 64,
  "tab-bottom": 26,
  "tab-gap": 10,
  "tab-icon": 26,
  "tab-word": 11,
  // The page sheet: the presenting screen pushed back behind it (its top, its
  // sides, its corner), the sheet's top and corner, its bar's top.
  "behind-top": 56,
  "behind-x": 16,
  "behind-radius": 12,
  "sheet-top": 66,
  "sheet-radius": 38,
  "sheet-bar-top": 16,
  // The home indicator.
  home: 140,
  "home-h": 5,
  "home-bottom": 8,
}

/** Where a finger presses the app's controls, in points from the app's top-left
 *  corner on a 402-point-wide screen (`SCREEN_PT`, an iPhone 16 Pro's): what a
 *  story taps (`touch`), placed by the app that draws them. */
export const CONTACTS_APP_CONTROLS = {
  /** The preview sheet's done, the blue ✓, which saves the contact. */
  done: {
    x: PT.margin + PT.bar / 2,
    y: PT["sheet-top"] + PT["sheet-bar-top"] + PT.bar / 2,
  },
  /** The screen's width, in the same points. */
  width: SCREEN_PT,
} as const

/** Where a card at rest starts: under the page's bar, or under the sheet's. */
const HEAD = {
  page: PT["safe-top"] + PT.bar + 18,
  sheet: PT["sheet-bar-top"] + PT.bar + 18,
}

/** The room after the last group: the tab bar and its gap on the page, the home
 *  indicator's band on the sheet. */
const FOOT = {
  page: PT.tab + PT["tab-bottom"] + PT.gap,
  sheet: PT["safe-bottom"] + PT.gap,
}

/** Any colour as the poster can carry it: everything on the poster is white, so
 *  its lightness is held where white reads on it (OKLCH 0.40 to 0.56) and its
 *  chroma muted as iOS mutes a photo's (at most 0.12), its hue kept. A bright sky
 *  or a pale wall comes out the same colour, deep enough. */
const settle = (tint: string) =>
  `oklch(from ${tint} clamp(0.4, l, 0.56) min(c, 0.12) h)`

/** The poster's top, a touch lighter: what the status bar stands on. */
const topOf = (tint: string) =>
  `color-mix(in oklab, ${settle(tint)}, white 10%)`

/** The poster's ground from its colour: a touch lighter at the top, a touch
 *  deeper at the foot, as iOS washes a photo's colour down the screen. */
const posterOf = (tint: string) =>
  `linear-gradient(180deg, ${topOf(tint)}, ${settle(tint)} 45%, color-mix(in oklab, ${settle(tint)}, black 22%))`

/** What the frame draws outside this box, for the frame's `style`: the status
 *  bar's ink (white over the poster, and over a sheet's black surround) and the
 *  screen's ground under it, the poster's top. */
export function contactsAppScreen(
  view: ContactsAppView,
  average: string = NO_PHOTO,
): React.CSSProperties {
  return {
    "--device-status-ink": "#fff",
    "--device-screen-background": sheet(view) ? "#000" : topOf(average),
  } as React.CSSProperties
}

type Kind = "phone" | "email" | "url" | "address"

/** The glyph at the end of a row, by what it is: call, write, open, find. */
const TRAIL: Record<Kind, typeof Phone> = {
  phone: Phone,
  email: Mail,
  url: Compass,
  address: MapPin,
}

/** iOS's own names for a vCard's types; a field with none is "other". */
const TYPE_NAME: Record<string, string> = {
  cell: "mobile",
  iphone: "iPhone",
}

/** A field's label as the Contacts app prints it: Apple's built-in labels
 *  (`_$!<HomePage>!$_`) in lowercase, a custom one as written, a TYPE in iOS's
 *  word for it. */
function labelOf(field: VCardField): string {
  const label = field.label
  if (!label) return "other"
  const builtin = /^_\$!<(.+)>!\$_$/.exec(label)
  if (builtin?.[1]) return builtin[1].toLowerCase()
  return TYPE_NAME[label] ?? label
}

const initials = (name: string) =>
  name
    .split(/\s+/)
    .filter(Boolean)
    .slice(0, 2)
    .map((w) => w[0]?.toUpperCase())
    .join("")

/** Liquid glass on the poster: the one material (material-glass), dark so its
 *  words are white, its face a breath of white over the blur, as iOS's controls
 *  stand on a poster. Each use states its corner, half its height. */
const GLASS = cn(
  glassVariants({ shape: "surface", scheme: "dark" }),
  "flex items-center justify-center text-white [--glass-tint:rgb(255_255_255/0.14)]",
)
/** A bar's round glass button: back, close. */
const BAR_CIRCLE =
  "size-(--ag-contacts-bar) [--glass-radius:calc(var(--ag-contacts-bar)/2)]"
/** A group of rows: a lighter wash of the poster, rounded. */
const GROUP =
  "mx-(--ag-contacts-margin) overflow-hidden rounded-(--ag-contacts-radius) bg-white/[0.14]"
/** A row after the first carries the hairline above it, inset to the text. */
const ROW =
  "relative flex items-center gap-(--ag-contacts-margin) px-(--ag-contacts-margin) py-(--ag-contacts-row-y) not-first:before:absolute not-first:before:top-0 not-first:before:right-(--ag-contacts-margin) not-first:before:left-(--ag-contacts-margin) not-first:before:h-px not-first:before:bg-white/20 not-first:before:content-['']"
const LABEL =
  "text-(length:--ag-contacts-label) leading-(--ag-contacts-label-leading) text-white"
const VALUE =
  "text-(length:--ag-contacts-value) leading-(--ag-contacts-value-leading) text-white"

function Field({ kind, field }: { kind: Kind; field: VCardField }) {
  const Trail = TRAIL[kind]
  return (
    <div data-slot="contacts-app-row" className={ROW}>
      <div className="min-w-0 flex-1">
        <div className={LABEL}>{labelOf(field)}</div>
        <div className={cn(VALUE, "break-words")}>{field.value}</div>
      </div>
      <Trail
        className="size-(--ag-contacts-trail) shrink-0 text-white"
        strokeWidth={2}
      />
    </div>
  )
}

/** One group per kind of field, in the order the card lists them. */
function Fields({ card }: { card: VCard }) {
  const groups: [Kind, VCardField[]][] = [
    ["phone", card.phones],
    ["email", card.emails],
    ["url", card.urls],
    ["address", card.addresses],
  ]
  return (
    <>
      {groups
        .filter(([, fields]) => fields.length > 0)
        .map(([kind, fields]) => (
          <div key={kind} data-slot="contacts-app-section" className={GROUP}>
            {fields.map((field) => (
              <Field key={`${kind}:${field.value}`} kind={kind} field={field} />
            ))}
          </div>
        ))}
      {card.note && (
        <div data-slot="contacts-app-section" className={GROUP}>
          <div data-slot="contacts-app-row" className={ROW}>
            <div className="min-w-0 flex-1">
              <div className={LABEL}>notes</div>
              <div className={cn(VALUE, "whitespace-pre-line break-words")}>
                {card.note}
              </div>
            </div>
          </div>
        </div>
      )}
    </>
  )
}

/** A group of one-line commands, `lit` the row under a finger. */
function Commands({ rows, lit }: { rows: string[]; lit?: string }) {
  return (
    <div data-slot="contacts-app-section" className={GROUP}>
      {rows.map((row) => (
        <div
          key={row}
          data-slot="contacts-app-row"
          data-pressed={row === lit ? "" : undefined}
          className={cn(ROW, VALUE, "data-pressed:bg-white/20")}
        >
          {row}
        </div>
      ))}
    </div>
  )
}

/** The four channels, each present when the card holds a way to reach it. */
function channelsOf(card: VCard) {
  const phone = card.phones.length > 0
  const email = card.emails.length > 0
  return [
    { name: "message", Icon: MessageCircle, on: phone },
    { name: "call", Icon: Phone, on: phone },
    { name: "video", Icon: Video, on: phone || email },
    { name: "mail", Icon: Mail, on: email },
  ]
}

function Header({ card }: { card: VCard }) {
  return (
    <div className="flex flex-col items-center">
      <div
        data-slot="contacts-app-photo"
        className="grid size-(--ag-contacts-photo) shrink-0 place-items-center overflow-hidden rounded-full bg-white/20 font-semibold text-(length:--ag-contacts-monogram) text-white"
      >
        {card.photo ? (
          <Image src={card.photo} alt="" className="size-full object-cover" />
        ) : (
          initials(card.name)
        )}
      </div>
      <div className="mt-(--ag-contacts-photo-gap) px-(--ag-contacts-margin) text-center font-bold text-(length:--ag-contacts-title) leading-(--ag-contacts-title-leading) tracking-[-0.01em] text-white [text-shadow:0_1px_8px_rgb(0_0_0/0.18)]">
        {card.name}
      </div>
      {/* The line under the name: the company, else the nickname, as iOS 26's
          poster sets it. */}
      {(card.org ?? card.nickname) && (
        <div className="px-(--ag-contacts-margin) text-center font-semibold text-(length:--ag-contacts-sub) leading-(--ag-contacts-sub-leading) text-white">
          {card.org ?? card.nickname}
        </div>
      )}
      <div
        data-slot="contacts-app-actions"
        className="mt-(--ag-contacts-head-gap) flex justify-center gap-(--ag-contacts-action-gap)"
      >
        {channelsOf(card).map(({ name, Icon, on }) => (
          <div
            key={name}
            data-on={on ? "" : undefined}
            className={cn(
              GLASS,
              "size-(--ag-contacts-action) [--glass-radius:calc(var(--ag-contacts-action)/2)] *:col-start-1 *:row-start-1",
              // Off, the glyph is the poster's colour lifted, opaque: dimmed,
              // never see-through.
              "grid text-(--ag-contacts-off) data-on:text-white",
            )}
          >
            <Icon
              className="size-(--ag-contacts-icon)"
              fill="currentColor"
              strokeWidth={1.4}
            />
            {name === "mail" && (
              // The flap cut out of the filled envelope, as SF's envelope.fill:
              // lucide draws its body over the flap, so the flap is drawn again
              // alone, on top, in the poster's colour.
              <Mail
                className="size-(--ag-contacts-icon) text-(--ag-contacts-tint) [&>rect]:hidden"
                strokeWidth={1.6}
              />
            )}
          </div>
        ))}
      </div>
    </div>
  )
}

/** iOS 26's scroll edge: the poster fading in under a bar's glass, so what has
 *  scrolled up passes softly beneath the buttons. */
function ScrollEdge({ head }: { head: string }) {
  return (
    <div
      className={cn(
        "pointer-events-none absolute inset-x-0 top-0 bg-linear-to-b from-(--ag-contacts-top) from-45% to-transparent",
        head,
      )}
    />
  )
}

/** The keypad's glyph, SF's circle.grid.3x3.fill: three rows of three dots. */
function Keypad({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      aria-hidden
      className={className}
      fill="currentColor"
    >
      {[4, 12, 20].flatMap((y) =>
        [4, 12, 20].map((x) => (
          <circle key={`${x}${y}`} cx={x} cy={y} r={2.6} />
        )),
      )}
    </svg>
  )
}

/** The tab bar at the foot, glass: Calls, Contacts (the one open, lit), Keypad;
 *  search a glass circle of its own beside it. */
function TabBar() {
  const tabs = [
    { word: "Calls", Icon: Clock },
    { word: "Contacts", Icon: CircleUser, open: true },
    { word: "Keypad", Icon: Keypad },
  ]
  return (
    <div className="absolute inset-x-(--ag-contacts-margin) bottom-(--ag-contacts-tab-bottom) flex h-(--ag-contacts-tab) gap-(--ag-contacts-tab-gap)">
      <div
        className={cn(
          GLASS,
          "flex-1 gap-0 p-[0.6cqw] [--glass-radius:calc(var(--ag-contacts-tab)/2)]",
        )}
      >
        {tabs.map(({ word, Icon, open }) => (
          <div
            key={word}
            className={cn(
              "flex h-full flex-1 flex-col items-center justify-center gap-[0.6cqw] rounded-full",
              open && "bg-white/[0.18]",
            )}
          >
            <Icon className="size-(--ag-contacts-tab-icon)" strokeWidth={2.2} />
            <span className="font-semibold text-(length:--ag-contacts-tab-word) leading-none">
              {word}
            </span>
          </div>
        ))}
      </div>
      <div
        className={cn(
          GLASS,
          "aspect-square h-full [--glass-radius:calc(var(--ag-contacts-tab)/2)]",
        )}
      >
        <Search className="size-(--ag-contacts-tab-icon)" strokeWidth={2.4} />
      </div>
    </div>
  )
}

function HomeIndicator() {
  return (
    <div className="absolute bottom-(--ag-contacts-home-bottom) left-1/2 h-(--ag-contacts-home-h) w-(--ag-contacts-home) -translate-x-1/2 rounded-full bg-white" />
  )
}

/** The sheet over the screen it was raised from, on the same poster. Its card
 *  scrolls as iOS lets a finger scroll it: from the top while it fits, and when
 *  it does not, to its foot, where the two commands are. A reversed column is a
 *  scroller that starts at its end; the spacer first in it takes whatever room
 *  the card leaves, so a card that fits stands at the top. Scrolled, the name
 *  moves into the bar (contacts-app.css). */
function ImportSheet({ card, pressed }: { card: VCard; pressed: boolean }) {
  return (
    <>
      <div className="absolute inset-x-(--ag-contacts-behind-x) top-(--ag-contacts-behind-top) bottom-0 rounded-t-(--ag-contacts-behind-radius) bg-[#2a2a2e]" />
      <div
        data-slot="contacts-app-sheet"
        // Its top corners cut by a path, never `overflow-hidden`: a clip with
        // uneven corners makes Chrome drop the masks of the glass inside it
        // (device-frame.css, the display).
        className="absolute inset-x-0 top-(--ag-contacts-sheet-top) bottom-0 rounded-t-(--ag-contacts-sheet-radius) bg-(image:--ag-contacts-poster) [clip-path:inset(0_round_var(--ag-contacts-sheet-radius)_var(--ag-contacts-sheet-radius)_0_0)]"
      >
        <div
          data-slot="contacts-app-scroller"
          className="flex h-full flex-col-reverse overflow-hidden"
        >
          <div className="grow" />
          <div
            className="flex shrink-0 flex-col gap-(--ag-contacts-gap)"
            style={{
              paddingTop: cqw(HEAD.sheet),
              paddingBottom: cqw(FOOT.sheet),
            }}
          >
            <Header card={card} />
            <Fields card={card} />
            <Commands
              rows={["Create New Contact", "Add to Existing Contact"]}
              lit={pressed ? "Create New Contact" : undefined}
            />
          </div>
        </div>
        <ScrollEdge head="h-[var(--ag-contacts-head-sheet)]" />
        <div
          data-slot="contacts-app-nav"
          className="absolute inset-x-(--ag-contacts-margin) top-(--ag-contacts-sheet-bar-top) flex h-(--ag-contacts-bar) items-center"
        >
          <div className={cn(GLASS, BAR_CIRCLE, "shrink-0")}>
            <X className="size-(--ag-contacts-close)" strokeWidth={2.6} />
          </div>
          <div
            data-slot="contacts-app-title"
            className={cn(
              VALUE,
              "min-w-0 flex-1 truncate px-(--ag-contacts-margin) text-center font-semibold",
            )}
            style={{ paddingRight: cqw(PT.bar + PT.margin) }}
          >
            {card.name}
          </div>
        </div>
      </div>
    </>
  )
}

/** A card's column scrolled `scroll` of the way through its box, with no
 *  measuring: placed that share down the box and lifted that share of its own
 *  height, its top meets the box's at 0 and its foot the box's at 1. A column no
 *  taller than the box fills it and never moves. */
function Scrolled({
  scroll,
  head,
  foot,
  children,
}: {
  scroll: number
  /** The room above it and after it, iOS points. */
  head: number
  foot: number
  children: React.ReactNode
}) {
  return (
    <div
      data-slot="contacts-app-card"
      className="absolute inset-x-0 flex min-h-full flex-col gap-(--ag-contacts-gap)"
      style={{
        top: `${scroll * 100}%`,
        transform: `translateY(${-scroll * 100}%)`,
        paddingTop: cqw(head),
        paddingBottom: cqw(foot),
      }}
    >
      {children}
    </div>
  )
}

/** The face in iOS 26's preview sheet: larger than in Contacts, the sheet's
 *  width nearly. */
const PREVIEW_PHOTO = 236

/** iOS 26's sheet over Safari when a .vcf is opened: the card on its poster,
 *  done (a blue ✓, which saves it) and share in glass at its top, no commands
 *  under the card. Scrolled with `scroll`. */
function PreviewSheet({ card, scroll }: { card: VCard; scroll: number }) {
  return (
    <div
      data-slot="contacts-app-sheet"
      // Its top corners cut by a path, never `overflow-hidden` (ImportSheet).
      className="absolute inset-x-0 top-(--ag-contacts-sheet-top) bottom-0 rounded-t-(--ag-contacts-sheet-radius) bg-(image:--ag-contacts-poster) [clip-path:inset(0_round_var(--ag-contacts-sheet-radius)_var(--ag-contacts-sheet-radius)_0_0)]"
      style={
        { "--ag-contacts-photo": cqw(PREVIEW_PHOTO) } as React.CSSProperties
      }
    >
      <Scrolled scroll={scroll} head={HEAD.sheet} foot={FOOT.sheet}>
        <Header card={card} />
        <Fields card={card} />
      </Scrolled>
      <ScrollEdge head="h-[var(--ag-contacts-head-sheet)]" />
      <div
        data-slot="contacts-app-nav"
        className="absolute inset-x-(--ag-contacts-margin) top-(--ag-contacts-sheet-bar-top) flex h-(--ag-contacts-bar) items-center justify-between"
      >
        <div
          className={cn(
            GLASS,
            BAR_CIRCLE,
            "[--glass-tint:rgb(10_132_255/0.92)]",
            // Pressed (a story's finger on it, `data-pressing` round the app),
            // as iOS 26's glass answers a press: it swells.
            "in-data-pressing:scale-115",
          )}
        >
          <Check className="size-(--ag-contacts-chevron)" strokeWidth={3} />
        </div>
        <div className={cn(GLASS, BAR_CIRCLE)}>
          <Share className="size-(--ag-contacts-close)" strokeWidth={2.4} />
        </div>
      </div>
    </div>
  )
}

function ContactPage({ card, scroll }: { card: VCard; scroll: number }) {
  const commands = [
    ...(card.phones.length > 0 ? ["Send Message"] : []),
    "Share Contact",
    "Add to Favorites",
  ]
  return (
    <>
      <Scrolled scroll={scroll} head={HEAD.page} foot={FOOT.page}>
        <Header card={card} />
        <Fields card={card} />
        <Commands rows={commands} />
      </Scrolled>
      <ScrollEdge head="h-[var(--ag-contacts-head-page)]" />
      <div
        data-slot="contacts-app-nav"
        className={cn(
          VALUE,
          "absolute inset-x-(--ag-contacts-margin) top-(--ag-contacts-safe-top) flex h-(--ag-contacts-bar) items-center justify-between",
        )}
      >
        <div className={cn(GLASS, BAR_CIRCLE)}>
          <ChevronLeft
            className="size-(--ag-contacts-chevron)"
            strokeWidth={2.8}
          />
        </div>
        <div
          className={cn(
            GLASS,
            "h-full px-(--ag-contacts-bar-x) font-semibold [--glass-radius:calc(var(--ag-contacts-bar)/2)]",
          )}
        >
          Edit
        </div>
      </div>
      <TabBar />
    </>
  )
}

/** What the picture shows, said in words. */
function describe(card: VCard, view: ContactsAppView, pressed: boolean) {
  const has = [
    card.phones.length > 0 && "a phone number",
    card.emails.length > 0 && "an email",
    card.urls.length > 0 && "a link",
    card.addresses.length > 0 && "an address",
    card.note && "a note",
  ].filter(Boolean)
  const holding = has.length > 0 ? `, with ${has.join(", ")}` : ""
  switch (view) {
    case "preview":
      return `iPhone, a contact file opened from Safari: ${card.name}${holding}, to save with done`
    case "import":
      return `iPhone, a contact file opened: ${card.name}${holding}, offering Create New Contact${pressed ? ", pressed" : ""} or Add to Existing Contact`
    case "contact":
      return `iPhone Contacts: the card of ${card.name}${holding}`
  }
}

export function ContactsApp({
  vcard,
  view,
  pressed = false,
  scroll = 0,
  average = NO_PHOTO,
  className,
}: {
  /** The .vcf's text (lib/vcard-core reads it). */
  vcard: string
  /** `preview`: the sheet iOS 26 raises over Safari for a just-opened .vcf.
   *  `import`: that sheet as iOS drew it before. `contact`: the saved card in the
   *  Contacts app. */
  view: ContactsAppView
  /** `import` only: Create New Contact lit under a finger. */
  pressed?: boolean
  /** `preview` and `contact`: how far the card is scrolled, 0 its top to 1 its
   *  foot (a story's scroll driving it). */
  scroll?: number
  /** The photo's mean colour (prepare-media's `average`, or `averageOf` on its
   *  bytes), the poster's colour; iOS's grey for a card with no photo. */
  average?: string
  className?: string
}) {
  if (pressed && view !== "import")
    throw new Error(
      "contacts-app: `pressed` is the import sheet's, not the card's",
    )
  if (scroll !== 0 && view === "import")
    throw new Error(
      "contacts-app: the import sheet rests at its foot; `scroll` is the preview's and the card's",
    )
  if (scroll < 0 || scroll > 1)
    throw new Error(`contacts-app: \`scroll\` is 0..1, got ${scroll}`)
  const card = parseVcard(vcard)
  const vars = {
    ...Object.fromEntries(
      Object.entries(PT).map(([k, v]) => [`--ag-contacts-${k}`, cqw(v)]),
    ),
    "--ag-contacts-head-page": cqw(HEAD.page),
    "--ag-contacts-head-sheet": cqw(HEAD.sheet),
    "--ag-contacts-tint": settle(average),
    "--ag-contacts-top": topOf(average),
    "--ag-contacts-off": `color-mix(in oklab, ${settle(average)}, white 38%)`,
    "--ag-contacts-poster": posterOf(average),
  } as React.CSSProperties
  return (
    <div
      role="img"
      aria-label={describe(card, view, pressed)}
      data-slot="contacts-app"
      data-view={view}
      className={cn(
        "@container absolute inset-0 overflow-hidden font-[family-name:-apple-system,BlinkMacSystemFont,system-ui,sans-serif] antialiased select-none",
        sheet(view) ? "bg-black" : "bg-(image:--ag-contacts-poster)",
        className,
      )}
      style={vars}
    >
      <div aria-hidden className="absolute inset-0">
        {view === "preview" ? (
          <PreviewSheet card={card} scroll={scroll} />
        ) : view === "import" ? (
          <ImportSheet card={card} pressed={pressed} />
        ) : (
          <ContactPage card={card} scroll={scroll} />
        )}
        <HomeIndicator />
      </div>
    </div>
  )
}
contacts-app.css
@layer theme, base, components, utilities;

@layer components {
  /* The sheet's compact title (the name in its bar) shows only while the card
     overflows the sheet, as iOS shows it once the header has scrolled under the
     bar. CSS reads the overflow itself: the scroller's timeline is active only
     when it has something to scroll, and an animation on an inactive timeline has
     no effect, so a constant keyframe on it is an "overflowing" switch. Keyframes,
     and an `animation-timeline` that must follow the `animation` shorthand (which
     resets it), are what no utility orders reliably. */
  @keyframes ag-contacts-overflowing {
    from,
    to {
      opacity: 1;
    }
  }
  [data-slot="contacts-app-sheet"] {
    timeline-scope: --ag-contacts-sheet;
  }
  [data-slot="contacts-app-scroller"] {
    scroll-timeline: --ag-contacts-sheet y;
  }
  [data-slot="contacts-app-title"] {
    opacity: 0;
    animation: ag-contacts-overflowing linear both;
    animation-timeline: --ag-contacts-sheet;
  }
}