Reference

Images & fonts

Images and fonts on White Tree Digital follow two firm conventions: Sanity-sourced images render through a hand-rolled <img> + urlFor(...) pipeline (never Astro's <Image>), and the three brand typefaces are self-hosted as variable WOFF2 files capped at six total — no Google Fonts CDN, ever.

The single rule that ties the image side together: a Sanity image field is not safe to truthy-check, and urlFor() throws on an asset-less object. Get the guard wrong and a whole page silently blanks. This page covers the image pipeline, that footgun, allowed formats, and the font files. For font families, axes, and the type scale, see Typography; for the hard limits these conventions exist to satisfy, see Performance budgets.


The Sanity image pipeline

Sanity images do not go through Astro's <Image> component. They use a raw <img> tag, a URL built by urlFor(...), and a hand-written srcset.

The builder lives in website/src/lib/image.ts. It wraps @sanity/image-url and pre-applies .auto('format') and .fit('max'):

export function urlFor(source: SanityImage) {
  return builder.image(source).auto('format').fit('max')
}

Every section that renders a Sanity image repeats the same two local helpers, calling .width(W).format('webp').url() and assembling a width-descriptor srcset by hand. From AboutSection.astro (the widths vary per section — 480/720/960/1280 here, 640/960/1280/1600 in the hero):

function imgUrl(img?: SanityImage, w = 800): string {
  if (!img?.asset) return '';
  return urlFor(img).width(w).format('webp').url();
}

function imgSrcSet(img?: SanityImage): string {
  if (!img?.asset) return '';
  return [480, 720, 960, 1280]
    .map((w) => `${urlFor(img).width(w).format('webp').url()} ${w}w`)
    .join(', ');
}

The rendered markup is a plain <img> with srcset, an explicit sizes, intrinsic width/height, and loading="lazy":

<img
  src={imageUrl}
  srcset={imageSrcSet}
  sizes="(min-width: 1024px) 40vw, 100vw"
  alt={imageAlt}
  width="800"
  height="1000"
  class="absolute inset-0 h-full w-full object-cover"
  loading="lazy"
/>

Never use Astro's <Image> for Sanity images

Astro's <Image> reprocesses remote URLs through its own optimizer, which clobbers Sanity's transform pipeline — the .width().format('webp') parameters baked into the urlFor URL are lost. Reserve <Image> exclusively for local public/ assets (the OG default, decorative art). For anything coming out of Sanity, use raw <img> + urlFor(...) + a hand-rolled srcset, as every *Section.astro does today.


The asset-guard footgun

urlFor() throws when its source has no resolvable asset. This is the single most important thing to know about images in this codebase, and it is documented as a CONTRACT on the function itself in image.ts.

The trap is that a Sanity image field is not safe to truthy-check. Removing the uploaded asset in Studio while leaving any sub-field set (most commonly alt) leaves a ghost object{_type: 'image', alt: '…'} — which is truthy but has no asset. Calling .url() on it throws.

An asset-less image throws — and blanks the entire page

An unhandled throw in a section's frontmatter or template discards the whole SSR stream after the doctype, so the page renders blank. Worse, the Cloudflare dev adapter swallows the trace, so it fails silently in local dev. Always guard on ?.assetnever on the object:

// ❌ truthy ghost object slips through, .url() throws, page blanks
if (!img) ...
{img ? <img src={urlFor(img)...} /> : ...}

// ✅ guard the asset ref
if (!img?.asset) ...
{img?.asset ? <img src={urlFor(img)...} /> : ...}

Every imgUrl / imgSrcSet helper returns '' when !img?.asset, and every template gates its <img> on img?.asset (or on the empty-string URL the helper returns). Copy that pattern into any new section that renders a Sanity image.

The same trap applies to ogImageUrl(source), which returns a 1200×630 cropped JPG for og:image and short-circuits to undefined on an asset-less field:

export function ogImageUrl(source: SanityImage | undefined): string | undefined {
  if (!source?.asset) return undefined
  return builder.image(source).width(1200).height(630).fit('crop').format('jpg').url()
}

This is part of the broader "treat every Sanity value as nullable at read time" rule — required() in a schema is editor-side validation, not a read guarantee. See Components and the page-builder conventions for the array/reference/nested-access guards that travel alongside this one.


Allowed image formats

Per the performance budgets, the format choices are fixed:

UseFormat
Photos / content imagesAVIF or WebP (Sanity images request .format('webp'); urlFor also sets .auto('format'))
Logos / iconsSVG
Animated contentNone — no animated GIFs

Local public/ assets that do go through Astro's <Image> are emitted as AVIF/WebP by the optimizer. The hard ceilings these formats serve — first-load page weight under 2 MB, hero under 500 KB, ≤80 requests — are owned by Performance budgets; the anti-patterns page calls out hero video and GIFs explicitly.


Self-hosted fonts

The three brand families ship as self-hosted variable WOFF2 files declared with @font-face in website/src/styles/global.css. Each family is one variable WOFF2 per style (roman + italic) — the per-element weight/width cut is applied with font-variation-settings, not separate weight files — so the whole system is six files total:

@font-face {
  font-family: "Archivo";
  src: url("/fonts/archivo-vf.woff2") format("woff2");
  font-weight: 100 900;
  font-stretch: 62% 125%;
  font-style: normal;
  font-display: swap;
}

The six files (roman + italic for each of Archivo, Figtree, Aleo), referenced from public/fonts/:

archivo-vf.woff2          archivo-italic-vf.woff2
figtree-vf.woff2          figtree-italic-vf.woff2
Aleo-VF.woff2             aleo-italic-vf.woff2

Every face declares font-display: swap, and the --font-display / --font-sans / --font-mono @theme tokens point at the family names.

Six WOFF2 files is a hard cap — and no CDN

The font budget is WOFF2 only, ≤6 files (per Tailwind rules and the performance budgets). The fonts are self-hosted deliberately: no Google Fonts CDN, ever. Adding a separate weight file instead of using font-variation-settings, or pulling a face from a CDN, breaks both the cap and the self-hosting rule. Italics count toward the six — they exist as the second style of each variable family, not as extra weights.

Above-the-fold preload

Layout.astro preloads only the three roman variable fonts that appear above the fold — Archivo (display/hero/wordmark), Figtree (body), Aleo (the mono .eyebrow labels). The italics aren't above the fold, so they load on demand:

<link rel="preload" href="/fonts/archivo-vf.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/fonts/figtree-vf.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/fonts/Aleo-VF.woff2" as="font" type="font/woff2" crossorigin />

Re-measure JS that depends on text width after fonts swap

Because faces use font-display: swap, glyph widths change when the web fonts land. Any code that measures text geometry must wait for the swap: the Hero headline animation waits for fonts.ready before SplitText measures lines, and the Marquee re-measures after web fonts swap in. New layout code that reads getBoundingClientRect on text should do the same.

Font families, axes, the per-element font-variation-settings cuts, and the full type scale are documented on Typography.


Where this lives

  • website/src/lib/image.tsurlFor() (the throws-on-asset-less CONTRACT) and ogImageUrl().
  • website/src/components/sections/*.astro — the per-section imgUrl / imgSrcSet helpers and guarded <img> markup (e.g. AboutSection.astro, HeroExplorerSection.astro, ContentImageSection.astro, StickyScrollSection.astro).
  • website/src/styles/global.css — the six @font-face declarations and the @theme font tokens.
  • website/src/layouts/Layout.astro — the three above-the-fold font preload links.
  • website/public/fonts/ — the self-hosted WOFF2 files (6-file cap).
  • website/CLAUDE.md — the canonical image-guard and font-budget rules ("Conventions" and "Performance budgets").
Previous
Security