Start a project

Elements

Elements

The full set of building blocks — layout, text, inputs, overlays, navigation and data — all in the ADS voice.

Layout & text

AdsToggle

A single toggle button. Off is raised; on is pressed in.

off
vue
<AdsToggle v-model="bold" icon="theme">Bold</AdsToggle>
<AdsToggle v-model="grid" icon="grid" label="Show grid" />
AdsToggle props
PropTypeDefaultDescription
v-modelbooleanfalse
iconstringGlyph name.
labelstringAccessible name. Required when there is no slot text.
Source · ads/app/components/AdsToggle.vue
vue
<script setup lang="ts">
// A single toggle button — a setting that flips on and stays that way. Off, it
// stands proud like any button; on, it stays pressed into the page with its
// text brightened. The shadow inversion is the state, so the accent fill stays
// free for the one action on the screen. With only a glyph and no words, the
// `label` names it to assistive tech.
withDefaults(defineProps<{ modelValue?: boolean; icon?: string; size?: number; label?: string }>(), {
  modelValue: false,
  size: 16,
  label: undefined,
})
const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void }>()
</script>

<template>
  <button
    type="button"
    class="ads-toggle"
    :class="{ 'is-on': modelValue }"
    :aria-pressed="modelValue"
    :aria-label="label"
    @click="emit('update:modelValue', !modelValue)"
  >
    <Icon v-if="icon" :name="icon" :size="size" />
    <slot />
  </button>
</template>

<style scoped>
.ads-toggle {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2);
  padding: var(--sp-2) var(--sp-4);
  border-radius: var(--radius-sm);
  font-size: 0.88rem;
  font-weight: 500;
  color: var(--text-soft);
  background: var(--raised);
  box-shadow: var(--soft-raised-sm);
  transition:
    color var(--dur-control) var(--ease),
    box-shadow var(--dur-control) var(--ease);
}

.ads-toggle:hover {
  color: var(--accent);
}

.ads-toggle.is-on {
  color: var(--accent);
  background: var(--surface);
  box-shadow: var(--soft-inset-sm);
}
</style>

AdsToggleGroup

A labelled group of toggle buttons.

vue
<AdsToggleGroup
  v-model="sel"
  label="Text style"
  :options="[{ label: 'Bold', value: 'bold' }, { label: 'Italic', value: 'italic' }]"
/>
AdsToggleGroup props
PropTypeDefaultDescription
v-model(string | number)[][]The values that are on.
options{ label, value, icon? }[]The buttons.
labelstringRequired. Names the group for assistive tech.
Source · ads/app/components/AdsToggleGroup.vue
vue
<script setup lang="ts">
// A row of toggle buttons where several can be on at once (bold and italic).
// Not a segmented control: that is one choice of two to five, and fills the
// chosen one with the accent. Here every pressed button stays pressed into the
// page, as AdsToggle does, so three active toggles do not spend the accent
// three times. `label` names the group.
export type ToggleOption = { label: string; value: string | number }

const props = withDefaults(defineProps<{ modelValue?: (string | number)[]; options: ToggleOption[]; label: string }>(), {
  modelValue: () => [],
})
const emit = defineEmits<{ (e: 'update:modelValue', v: (string | number)[]): void }>()

function toggle(v: string | number) {
  const cur = props.modelValue
  emit('update:modelValue', cur.includes(v) ? cur.filter((x) => x !== v) : [...cur, v])
}
</script>

<template>
  <div class="ads-toggle-group" role="group" :aria-label="label">
    <button
      v-for="o in options"
      :key="o.value"
      type="button"
      class="ads-toggle-group__button"
      :class="{ 'is-on': modelValue.includes(o.value) }"
      :aria-pressed="modelValue.includes(o.value)"
      @click="toggle(o.value)"
    >
      {{ o.label }}
    </button>
  </div>
</template>

<style scoped>
.ads-toggle-group {
  display: inline-flex;
  flex-wrap: wrap;
  gap: var(--sp-1);
}

.ads-toggle-group__button {
  padding: var(--sp-2) var(--sp-3);
  border-radius: var(--radius-sm);
  font-size: 0.83rem;
  color: var(--text-soft);
  background: var(--raised);
  box-shadow: var(--soft-raised-sm);
  white-space: nowrap;
  transition:
    color var(--dur-control) var(--ease),
    box-shadow var(--dur-control) var(--ease);
}

.ads-toggle-group__button:hover {
  color: var(--accent);
}

.ads-toggle-group__button.is-on {
  color: var(--accent);
  background: var(--surface);
  box-shadow: var(--soft-inset-sm);
}
</style>

AdsAvatar

A soft circle with initials.

vue
<AdsAvatar name="Artelier Studio" :size="40" />
<!-- beside a visible name, keep it out of the reading order -->
<AdsAvatar name="Artelier Studio" decorative />
AdsAvatar props
PropTypeDefaultDescription
namestringRequired. Initials come from it; read out unless decorative.
sizenumber40
decorativebooleanfalseHide from assistive tech when the name is shown next to it.
Source · ads/app/components/AdsAvatar.vue
vue
<script setup lang="ts">
// An avatar — a soft circle with initials. Greyscale, like the chrome: a face
// or a brand colour here would out-shout the data beside it. It names the
// person to assistive tech unless the name is already written next to it, in
// which case `decorative` keeps it from being read twice.
const props = withDefaults(defineProps<{ name: string; size?: number; decorative?: boolean }>(), {
  size: 40,
  decorative: false,
})

const initials = computed(() =>
  props.name
    .split(/\s+/)
    .filter(Boolean)
    .map((p) => p[0])
    .slice(0, 2)
    .join('')
    .toUpperCase(),
)
</script>

<template>
  <span
    class="ads-avatar"
    :style="{ width: `${size}px`, height: `${size}px`, fontSize: `${Math.round(size * 0.4)}px` }"
    :role="decorative ? undefined : 'img'"
    :aria-label="decorative ? undefined : name"
    :aria-hidden="decorative ? 'true' : undefined"
  >
    <span aria-hidden="true">{{ initials }}</span>
  </span>
</template>

<style scoped>
.ads-avatar {
  display: inline-grid;
  place-items: center;
  flex: none;
  border-radius: 50%;
  background: var(--raised);
  box-shadow: var(--soft-raised-sm);
  font-family: var(--font-label);
  font-weight: 500;
  color: var(--text-soft);
  letter-spacing: 0.04em;
}
</style>

AdsSeparator

A horizontal rule.

Above


Below

vue
<AdsSeparator />
Source · ads/app/components/AdsSeparator.vue
vue
<script setup lang="ts">
// A horizontal rule — the whisper that separates rows or sections. One pixel
// of `--line`, never a heavier rule: depth does the structural work here.
</script>

<template>
  <hr class="ads-separator" />
</template>

<style scoped>
.ads-separator {
  height: 1px;
  margin: 0;
  border: 0;
  background: var(--line);
}
</style>

AdsCollapsible

A single collapsible.

Why the chrome is grey
So colour means something when it appears.
vue
<AdsCollapsible v-model:open="open" title="Why the chrome is grey">
  So colour means something when it appears.
</AdsCollapsible>
AdsCollapsible props
PropTypeDefaultDescription
titlestringRequired. The summary line.
v-model:openbooleanfalse
Source · ads/app/components/AdsCollapsible.vue
vue
<script setup lang="ts">
// A single collapsible — native details/summary, so it works before JS and the
// browser does the keyboard and the aria-expanded for us. For the reasoning
// most readers skip and some want; never for something every reader needs.
// `v-model:open` follows the reader's clicks.
const open = defineModel<boolean>('open', { default: false })
defineProps<{ title: string }>()

function onToggle(e: Event) {
  open.value = (e.target as HTMLDetailsElement).open
}
</script>

<template>
  <details class="ads-collapsible" :open="open || undefined" @toggle="onToggle">
    <summary class="ads-collapsible__summary">{{ title }}</summary>
    <div class="ads-collapsible__panel"><slot /></div>
  </details>
</template>

<style scoped>
.ads-collapsible {
  border-radius: var(--radius-sm);
  background: var(--surface);
  box-shadow: var(--soft-inset-sm);
  overflow: hidden;
}

.ads-collapsible__summary {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  padding: var(--sp-3) var(--sp-4);
  cursor: pointer;
  font-size: 0.9rem;
  color: var(--text-soft);
  list-style: none;
  transition: color var(--dur-control) var(--ease);
}

.ads-collapsible__summary::-webkit-details-marker {
  display: none;
}

.ads-collapsible__summary:hover {
  color: var(--accent);
}

/* The chevron: two strokes of currentColor, so it brightens with the text. */
.ads-collapsible__summary::before {
  content: '';
  width: 7px;
  height: 7px;
  flex: none;
  border-right: 1.5px solid currentColor;
  border-bottom: 1.5px solid currentColor;
  transform: rotate(-45deg);
  transition: transform var(--dur-control) var(--ease);
}

.ads-collapsible[open] > .ads-collapsible__summary::before {
  transform: rotate(45deg);
}

.ads-collapsible__panel {
  padding: 0 var(--sp-4) var(--sp-4) 34px;
  font-size: 0.86rem;
  color: var(--subdued);
}
</style>

AdsDatePicker

One ISO date, typed or picked from a calendar, with presets.

vue
<AdsDatePicker
  v-model="date"
  label="Crawl date"
  :presets="[{ label: 'Today', offset: 0 }, { label: '7 days ago', offset: -7 }]"
/>
AdsDatePicker props
PropTypeDefaultDescription
v-modelstringISO date, YYYY-MM-DD.
labelstringRequired. Visible field label.
presets{ label, offset }[]Today, Yesterday, 7 days agoOffsets in days from today.
hintstring
Source · ads/app/components/AdsDatePicker.vue
vue
<script setup lang="ts">
// One date, typed or picked. The field takes ISO (YYYY-MM-DD) in the data
// face, so what you type is what you read back, and it says plainly when a
// date will not parse. The calendar opens from its own labelled button in a
// popover — Escape closes it and focus goes back to the button. Presets are
// offsets from today, so "7 days ago" always means a real date.
export type DatePreset = { label: string; offset: number }
const props = withDefaults(
  defineProps<{ label: string; presets?: DatePreset[]; hint?: string; id?: string }>(),
  {
    presets: () => [
      { label: 'Today', offset: 0 },
      { label: 'Yesterday', offset: -1 },
      { label: '7 days ago', offset: -7 },
    ],
    hint: '',
    id: '',
  },
)
const value = defineModel<string>({ default: '' })

const uid = useId()
const fieldId = computed(() => props.id || uid)
const root = ref<HTMLElement>()
const toggleBtn = ref<HTMLButtonElement>()
const pop = ref<HTMLElement>()
const open = ref(false)
const draft = ref(value.value)
const invalid = ref(false)

watch(value, (v) => {
  draft.value = v
  invalid.value = false
})

function pad(n: number) {
  return String(n).padStart(2, '0')
}
function iso(d: Date) {
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
function valid(s: string) {
  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s)
  if (!m) return false
  const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]))
  return iso(d) === s
}
function presetIso(p: DatePreset) {
  const d = new Date()
  d.setDate(d.getDate() + p.offset)
  return iso(d)
}

function commit() {
  const s = draft.value.trim()
  if (!s) {
    invalid.value = false
    value.value = ''
    return
  }
  invalid.value = !valid(s)
  if (!invalid.value) value.value = s
}

async function show() {
  open.value = true
  await nextTick()
  pop.value?.querySelector<HTMLElement>('.ads-calendar__day[tabindex="0"]')?.focus()
}

function hide(refocus = true) {
  open.value = false
  if (refocus) toggleBtn.value?.focus()
}

function onPick(v: string) {
  value.value = v
  hide()
}

function onKeydown(e: KeyboardEvent) {
  if (e.key === 'Escape' && open.value) {
    e.preventDefault()
    e.stopPropagation()
    hide()
  }
}

function onFocusout(e: FocusEvent) {
  if (open.value && !root.value?.contains(e.relatedTarget as Node)) hide(false)
}

const describedBy = computed(
  () => [invalid.value && `${fieldId.value}-err`, props.hint && `${fieldId.value}-hint`].filter(Boolean).join(' ') || undefined,
)
</script>

<template>
  <div ref="root" class="ads-date-picker ads-field-group" @keydown="onKeydown" @focusout="onFocusout">
    <label class="ads-label" :for="fieldId">{{ label }}</label>
    <div class="ads-date-picker__row">
      <input
        :id="fieldId"
        v-model="draft"
        class="ads-date-picker__input ads-field num"
        :class="{ 'is-invalid': invalid }"
        type="text"
        inputmode="numeric"
        placeholder="YYYY-MM-DD"
        autocomplete="off"
        :aria-invalid="invalid || undefined"
        :aria-describedby="describedBy"
        @change="commit"
        @keydown.enter.prevent="commit"
      >
      <button
        ref="toggleBtn"
        type="button"
        class="ads-icon-button"
        aria-label="Choose date from calendar"
        aria-haspopup="dialog"
        :aria-expanded="open"
        @click="open ? hide() : show()"
      >
        <Icon name="compass" :size="16" />
      </button>
      <div v-if="open" ref="pop" class="ads-date-picker__pop" role="dialog" :aria-label="`${label}: calendar`">
        <AdsCalendar :model-value="value" :label="label" @update:model-value="onPick" />
      </div>
    </div>
    <span v-if="invalid" :id="`${fieldId}-err`" class="ads-hint" role="alert">Use a real date as YYYY-MM-DD, for example {{ presetIso({ label: '', offset: 0 }) }}.</span>
    <span v-if="hint" :id="`${fieldId}-hint`" class="ads-hint">{{ hint }}</span>
    <div v-if="presets.length" class="ads-segmented" role="group" :aria-label="`${label} presets`">
      <button
        v-for="p in presets"
        :key="p.label"
        type="button"
        :class="{ 'is-on': value === presetIso(p) }"
        :aria-pressed="value === presetIso(p)"
        @click="value = presetIso(p)"
      >{{ p.label }}</button>
    </div>
  </div>
</template>

<style scoped>
.ads-date-picker__row {
  position: relative;
  display: flex;
  align-items: center;
  gap: var(--sp-2);
}

.ads-date-picker__input {
  flex: 1;
  min-width: 0;
}

.ads-date-picker__pop {
  position: absolute;
  top: calc(100% + 6px);
  right: 0;
  z-index: 30;
  border-radius: var(--radius-sm);
  background: var(--elevated);
  box-shadow: var(--soft-raised-lg);
  animation: ads-date-picker-in var(--dur-control) var(--ease);
}

@keyframes ads-date-picker-in {
  from {
    opacity: 0;
    transform: translateY(-4px);
  }
}
</style>

AdsLineChart

A trend over an ordered axis: series in slot order, a recessive scale, a table fallback.

Sessions per day, thousands
PointOrganicDirect
2026-10-01128
2026-10-02199
2026-10-031511
2026-10-042410
2026-10-053114
2026-10-062713
2026-10-073815
2026-10-083517
2026-10-094416
vue
<AdsLineChart
  label="Sessions per day, thousands"
  :labels="['2026-10-01', …, '2026-10-09']"
  :series="[
    { name: 'Organic', points: [12, 19, 15, 24, 31, 27, 38, 35, 44] },
    { name: 'Direct', points: [8, 9, 11, 10, 14, 13, 15, 17, 16] },
  ]"
/>
AdsLineChart props
PropTypeDefaultDescription
labelstringRequired. Names the chart and carries the unit.
series{ name, points }[]Drawn in slot order --series-1..8; a ninth and beyond fold into "Other".
pointsnumber[]Shorthand for a single series named by label.
labelsstring[]X-axis labels; first and last are shown, all go in the table fallback.
heightnumber160
Source · ads/app/components/AdsLineChart.vue
vue
<script setup lang="ts">
// A line chart for a trend over an ordered axis. Hue is spent only on the
// lines, in slot order (--series-1 first); past eight, the remainder is summed
// into a grey "Other" rather than inventing a ninth colour. Everything that is
// text — the scale, the axis ends, the key — is in the text tokens, never in a
// series colour, and the scale is recessive: two figures and one baseline, no
// grid, no area fill. `label` names the figure and carries the unit
// ("Sessions per day, thousands"); the same data sits in a table for screen
// readers.
export type LineSeries = { name: string; points: number[] }
const props = withDefaults(
  defineProps<{
    label: string
    series?: LineSeries[]
    points?: number[]
    labels?: string[]
    height?: number
  }>(),
  { series: () => [], points: () => [], labels: () => [], height: 160 },
)

const W = 600
const H = 160
const PAD = 6
const MAX_SLOTS = 8

const fmt = (n: number) => n.toLocaleString('en-GB', { maximumFractionDigits: 2 })

type Drawn = LineSeries & { colour: string; other: boolean }

const drawn = computed<Drawn[]>(() => {
  const all = props.series.length ? props.series : props.points.length ? [{ name: props.label, points: props.points }] : []
  const kept: Drawn[] = all.slice(0, MAX_SLOTS).map((s, i) => ({ ...s, colour: `var(--series-${i + 1})`, other: false }))
  const rest = all.slice(MAX_SLOTS)
  if (rest.length) {
    const len = Math.max(...rest.map((s) => s.points.length))
    const sum = Array.from({ length: len }, (_, i) => rest.reduce((t, s) => t + (s.points[i] ?? 0), 0))
    kept.push({ name: 'Other', points: sum, colour: 'var(--subdued)', other: true })
  }
  return kept
})

const length = computed(() => Math.max(0, ...drawn.value.map((s) => s.points.length)))

const scale = computed(() => {
  const values = drawn.value.flatMap((s) => s.points)
  if (!values.length) return { min: 0, max: 0 }
  return { min: Math.min(...values), max: Math.max(...values) }
})

function path(points: number[]) {
  if (points.length < 2) return ''
  const { min, max } = scale.value
  const span = max - min || 1
  const step = W / Math.max(length.value - 1, 1)
  return points
    .map((v, i) => `${i ? 'L' : 'M'}${(i * step).toFixed(1)} ${(H - PAD - ((v - min) / span) * (H - PAD * 2)).toFixed(1)}`)
    .join(' ')
}

const xLabels = computed(() =>
  Array.from({ length: length.value }, (_, i) => props.labels[i] ?? String(i + 1)),
)
</script>

<template>
  <figure class="ads-line-chart" :aria-label="label">
    <div class="ads-line-chart__plot" aria-hidden="true">
      <div class="ads-line-chart__scale num">
        <span>{{ fmt(scale.max) }}</span>
        <span>{{ fmt(scale.min) }}</span>
      </div>
      <svg
        class="ads-line-chart__svg"
        :viewBox="`0 0 ${W} ${H}`"
        preserveAspectRatio="none"
        :style="{ height: `${height}px` }"
        focusable="false"
      >
        <line class="ads-line-chart__baseline" x1="0" :y1="H - 0.5" :x2="W" :y2="H - 0.5" vector-effect="non-scaling-stroke" />
        <g class="ads-line-chart__lines">
          <path
            v-for="s in drawn"
            :key="s.name"
            :d="path(s.points)"
            fill="none"
            :stroke="s.colour"
            stroke-width="2"
            vector-effect="non-scaling-stroke"
            stroke-linecap="round"
            stroke-linejoin="round"
          />
        </g>
      </svg>
      <div v-if="length" class="ads-line-chart__axis num">
        <span>{{ xLabels[0] }}</span>
        <span>{{ xLabels[length - 1] }}</span>
      </div>
    </div>

    <ul class="ads-line-chart__key" aria-hidden="true">
      <li v-for="s in drawn" :key="s.name" class="ads-line-chart__entry">
        <span class="ads-line-chart__swatch" :style="{ background: s.colour }" />
        <span class="ads-line-chart__name">{{ s.name }}</span>
        <span class="ads-line-chart__last num">{{ fmt(s.points[s.points.length - 1] ?? 0) }}</span>
      </li>
    </ul>

    <table class="sr-only">
      <caption>{{ label }}</caption>
      <thead>
        <tr>
          <th scope="col">Point</th>
          <th v-for="s in drawn" :key="s.name" scope="col">{{ s.name }}</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="(x, i) in xLabels" :key="i">
          <th scope="row">{{ x }}</th>
          <td v-for="s in drawn" :key="s.name">{{ s.points[i] == null ? '' : fmt(s.points[i]!) }}</td>
        </tr>
      </tbody>
    </table>
  </figure>
</template>

<style scoped>
.ads-line-chart {
  display: grid;
  gap: var(--sp-3);
  margin: 0;
}

.ads-line-chart__plot {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  column-gap: var(--sp-2);
}

.ads-line-chart__scale {
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  align-items: flex-end;
  font-size: 0.68rem;
  color: var(--subdued);
}

.ads-line-chart__svg {
  display: block;
  width: 100%;
  overflow: visible;
}

.ads-line-chart__baseline {
  stroke: var(--line);
  stroke-width: 1;
}

.ads-line-chart__lines {
  animation: ads-line-chart-draw var(--dur-enter) var(--ease);
}

.ads-line-chart__axis {
  grid-column: 2;
  display: flex;
  justify-content: space-between;
  padding-top: var(--sp-1);
  font-size: 0.68rem;
  color: var(--subdued);
}

.ads-line-chart__key {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2) var(--sp-4);
  margin: 0;
  padding: 0;
  list-style: none;
}

.ads-line-chart__entry {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2);
  font-size: 0.8rem;
}

.ads-line-chart__swatch {
  width: 12px;
  height: 3px;
  border-radius: var(--pill);
}

.ads-line-chart__name {
  color: var(--text-soft);
}

.ads-line-chart__last {
  color: var(--text);
}

/* The lines draw left to right, once, on arrival. */
@keyframes ads-line-chart-draw {
  from {
    clip-path: inset(0 100% 0 0);
  }
  to {
    clip-path: inset(0 0 0 0);
  }
}
</style>

AdsAlertDialog

A confirmation that says its verb. Destructive by default, so the action stays off the accent.

vue
<AdsAlertDialog
  v-model:open="open"
  title="Delete project?"
  description="Its crawls, history and comparisons go with it."
  action-label="Delete project"
  @action="remove"
/>
AdsAlertDialog props
PropTypeDefaultDescription
v-model:openbooleanfalse
titlestringRequired. A question.
action-labelstringRequired. The verb and object — "Delete project".
descriptionstring
cancel-labelstringCancel
destructivebooleantrueKeeps the action off the accent and focuses Cancel first.
Source · ads/app/components/AdsAlertDialog.vue
vue
<script setup lang="ts">
// A confirmation that interrupts. It is for one irreversible step, so it says
// the verb on its button ("Delete project", never "OK") and, when that step is
// destructive, the button stays in the default style — the one accent on the
// screen never points at a loss — and focus starts on Cancel. Escape cancels,
// Tab stays inside, a click on the scrim does nothing (this is a decision, not
// a notice), and focus goes back to whatever opened it.
const props = withDefaults(
  defineProps<{
    title: string
    actionLabel: string
    description?: string
    cancelLabel?: string
    destructive?: boolean
  }>(),
  { description: '', cancelLabel: 'Cancel', destructive: true },
)
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ (e: 'action'): void; (e: 'cancel'): void }>()

const uid = useId()
const panel = ref<HTMLElement>()
const cancelBtn = ref<HTMLButtonElement>()
const actionBtn = ref<HTMLButtonElement>()
let returnTo: HTMLElement | null = null
let prevOverflow = ''

const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'

function cancel() {
  open.value = false
  emit('cancel')
}

function act() {
  open.value = false
  emit('action')
}

function onKeydown(e: KeyboardEvent) {
  if (e.key === 'Escape') {
    e.preventDefault()
    cancel()
    return
  }
  if (e.key !== 'Tab' || !panel.value) return
  const items = Array.from(panel.value.querySelectorAll<HTMLElement>(FOCUSABLE))
  if (!items.length) return
  const first = items[0]!
  const last = items[items.length - 1]!
  if (e.shiftKey && document.activeElement === first) {
    e.preventDefault()
    last.focus()
  } else if (!e.shiftKey && document.activeElement === last) {
    e.preventDefault()
    first.focus()
  }
}

watch(
  open,
  async (isOpen) => {
    if (!false) return
    if (isOpen) {
      returnTo = document.activeElement as HTMLElement | null
      prevOverflow = document.body.style.overflow
      document.body.style.overflow = 'hidden'
      await nextTick()
      ;(props.destructive ? cancelBtn.value : actionBtn.value)?.focus()
    } else {
      document.body.style.overflow = prevOverflow
      returnTo?.focus?.()
      returnTo = null
    }
  },
  { immediate: true },
)

onBeforeUnmount(() => {
  if (false && open.value) document.body.style.overflow = prevOverflow
})
</script>

<template>
  <Teleport to="body">
    <div v-if="open" class="ads-alert-dialog" @keydown="onKeydown">
      <div class="ads-alert-dialog__scrim" aria-hidden="true" />
      <div
        ref="panel"
        class="ads-alert-dialog__panel ads-modal"
        role="alertdialog"
        aria-modal="true"
        :aria-labelledby="`${uid}-title`"
        :aria-describedby="description ? `${uid}-desc` : undefined"
      >
        <h2 :id="`${uid}-title`" class="ads-alert-dialog__title">{{ title }}</h2>
        <p v-if="description" :id="`${uid}-desc`" class="ads-alert-dialog__desc">{{ description }}</p>
        <slot />
        <div class="ads-alert-dialog__actions">
          <button ref="cancelBtn" type="button" class="btn btn-quiet" @click="cancel">{{ cancelLabel }}</button>
          <button ref="actionBtn" type="button" class="btn" :class="{ 'btn-accent': !destructive }" @click="act">
            {{ actionLabel }}
          </button>
        </div>
      </div>
    </div>
  </Teleport>
</template>

<style scoped>
.ads-alert-dialog {
  position: fixed;
  inset: 0;
  z-index: 60;
  display: grid;
  place-items: center;
  padding: var(--sp-5);
}

.ads-alert-dialog__scrim {
  position: absolute;
  inset: 0;
  background: rgb(0 0 0 / 0.45);
  backdrop-filter: blur(2px);
}

.ads-alert-dialog__panel {
  position: relative;
  display: grid;
  gap: var(--sp-3);
  animation: ads-alert-dialog-in var(--dur-move) var(--ease);
}

.ads-alert-dialog__title {
  margin: 0;
  font-family: var(--font-display);
  font-size: 1.2rem;
  font-weight: 600;
  color: var(--text);
}

.ads-alert-dialog__desc {
  margin: 0;
  font-size: 0.9rem;
  line-height: 1.55;
  color: var(--text-soft);
}

.ads-alert-dialog__actions {
  display: flex;
  justify-content: flex-end;
  gap: var(--sp-3);
  margin-top: var(--sp-3);
}

@keyframes ads-alert-dialog-in {
  from {
    opacity: 0;
    transform: translateY(8px) scale(0.98);
  }
}
</style>

AdsTypography

Text in one of the system's type roles.

Less, but done well.

A quieter heading

The chrome is grey so the data can be coloured.

A caption, for the fine print.

vue
<AdsTypography role="display">Less, but done well.</AdsTypography>
<AdsTypography role="eyebrow">A quieter heading</AdsTypography>
Source · ads/app/components/AdsTypography.vue
vue
<script setup lang="ts">
// Text in one of the system's type roles, so a page never invents a size. Each
// role has one face: display for headlines, UI for reading, the label face for
// eyebrows, the data face for figures. Roles stay in text tokens — the chrome
// is grey so the data can be coloured. `as` picks the element, so a display
// line can still be the page's h1.
withDefaults(
  defineProps<{ as?: string; role?: 'display' | 'title' | 'heading' | 'body' | 'lede' | 'eyebrow' | 'caption' | 'mono' }>(),
  { as: 'p', role: 'body' },
)
</script>

<template>
  <component :is="as" class="ads-typography" :class="`is-${role}`"><slot /></component>
</template>

<style scoped>
.ads-typography {
  margin: 0;
}

.ads-typography.is-display {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: clamp(2.4rem, 5vw, 3.6rem);
  line-height: 1.05;
  letter-spacing: -0.02em;
  color: var(--text);
}

.ads-typography.is-title {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: clamp(1.7rem, 3vw, 2.3rem);
  line-height: 1.1;
  letter-spacing: -0.015em;
  color: var(--text);
}

.ads-typography.is-heading {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 1.25rem;
  line-height: 1.2;
  color: var(--text);
}

.ads-typography.is-body {
  font-family: var(--font);
  font-size: 0.95rem;
  line-height: 1.65;
  color: var(--text-soft);
}

.ads-typography.is-lede {
  font-family: var(--font);
  font-size: 1.05rem;
  line-height: 1.6;
  color: var(--text);
}

/* Eyebrows are labels, so they take the label face, as `.ads-label` does. */
.ads-typography.is-eyebrow {
  font-family: var(--font-label);
  font-size: 0.66rem;
  font-weight: 500;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--subdued);
}

.ads-typography.is-caption {
  font-family: var(--font);
  font-size: 0.78rem;
  line-height: 1.5;
  color: var(--subdued);
}

.ads-typography.is-mono {
  font-family: var(--mono);
  font-size: 0.9rem;
  font-variant-numeric: tabular-nums;
  font-feature-settings: 'tnum' 1;
  color: var(--text);
}
</style>

AdsAspectRatio

A container with a fixed aspect ratio.

16 / 9
vue
<AdsAspectRatio ratio="16 / 9">
  <img src="…" alt="…" />
</AdsAspectRatio>
Source · ads/app/components/AdsAspectRatio.vue
vue
<script setup lang="ts">
// A box that holds its proportions while its width changes, so an image or a
// chart reserves its space before it loads and nothing below it jumps. It is a
// well: content sits pressed into the card, never on the canvas.
withDefaults(defineProps<{ ratio?: string }>(), { ratio: '16 / 9' })
</script>

<template>
  <div class="ads-aspect-ratio" :style="{ aspectRatio: ratio }"><slot /></div>
</template>

<style scoped>
.ads-aspect-ratio {
  position: relative;
  width: 100%;
  overflow: hidden;
  border-radius: var(--radius-sm);
  background: var(--surface);
  box-shadow: var(--soft-inset-sm);
}
</style>

AdsLabel

A form label, in the label face.

vue
<AdsLabel for="email">Email address</AdsLabel>
<input id="email" class="ads-field" type="email" />
Source · ads/app/components/AdsLabel.vue
vue
<script setup lang="ts">
// A form label, above its field — in the label face, like every label in the
// system. It is the global `.ads-label`, so it cannot drift from the field
// labels the rules page shows. A field always has one: placeholder text is
// gone exactly when it is needed.
defineProps<{ for?: string }>()
</script>

<template>
  <label class="ads-label" :for="$props.for"><slot /></label>
</template>

AdsKbd

A keyboard shortcut, one key per cap.

⌘KEsc
vue
<AdsKbd :keys="['⌘', 'K']" />
AdsKbd props
PropTypeDefaultDescription
keysstring[]Required. One key per entry.
Source · ads/app/components/AdsKbd.vue
vue
<script setup lang="ts">
// A keyboard shortcut: one key, or a chord. Keys sit in the data face, pressed
// into the page like every other piece of reference text. Nested <kbd>s are
// how HTML spells "press these together".
defineProps<{ keys: string[] }>()
</script>

<template>
  <kbd class="ads-kbd">
    <kbd v-for="(k, i) in keys" :key="i" class="kbd">{{ k }}</kbd>
  </kbd>
</template>

<style scoped>
.ads-kbd {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-1);
  font-family: var(--mono);
}
</style>

AdsCodeBlock

A code block with a copy button.

ts
const chrome = grey
const data = coloured
vue
<AdsCodeBlock :code="'const n = 42'" language="ts" />
Source · ads/app/components/AdsCodeBlock.vue
vue
<script setup lang="ts">
// A code block with a copy button. Code wraps rather than scrolling sideways:
// with no scrollbars anywhere, a cut-off line would give no sign there is more.
// The button says what it copies, and the confirmation is announced, not just
// shown.
const props = withDefaults(defineProps<{ code: string; language?: string }>(), { language: '' })
const copied = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined

async function copy() {
  try {
    await navigator.clipboard.writeText(props.code)
  } catch {
    // Clipboard unavailable; the code is still readable and selectable.
    return
  }
  copied.value = true
  clearTimeout(timer)
  timer = setTimeout(() => (copied.value = false), 1600)
}

onBeforeUnmount(() => clearTimeout(timer))
</script>

<template>
  <div class="ads-code-block">
    <div class="ads-code-block__bar">
      <span v-if="language" class="ads-label">{{ language }}</span>
      <button type="button" class="ads-code-block__copy" @click="copy">
        <span aria-live="polite">{{ copied ? 'Copied' : 'Copy code' }}</span>
      </button>
    </div>
    <pre class="ads-code-block__pre"><code>{{ code }}</code></pre>
  </div>
</template>

<style scoped>
.ads-code-block {
  display: grid;
  gap: var(--sp-2);
  padding: var(--sp-3) var(--sp-4) var(--sp-4);
  border-radius: var(--radius-sm);
  background: var(--surface);
  box-shadow: var(--soft-inset-sm);
}

.ads-code-block__bar {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
}

.ads-code-block__copy {
  margin-left: auto;
  padding: var(--sp-1) var(--sp-3);
  border-radius: var(--pill);
  background: var(--raised);
  box-shadow: var(--soft-raised-sm);
  font-family: var(--mono);
  font-size: 0.68rem;
  color: var(--text-soft);
  transition:
    color var(--dur-control) var(--ease),
    box-shadow var(--dur-control) var(--ease);
}

.ads-code-block__copy:hover {
  color: var(--accent);
}

.ads-code-block__copy:active {
  box-shadow: var(--soft-inset-sm);
}

.ads-code-block__pre {
  margin: 0;
  white-space: pre-wrap;
  word-break: break-word;
  font-family: var(--mono);
  font-size: 0.8rem;
  line-height: 1.6;
  color: var(--text-soft);
}
</style>

AdsMarker

A status marker: a tone or grade, always with a label and its own glyph.

LivePausedDownDraftGrade 3
vue
<AdsMarker label="Live" tone="ok" />
<AdsMarker label="Paused" tone="warn" />
<AdsMarker label="Down" tone="bad" />
<AdsMarker label="Draft" />
<AdsMarker label="Grade 3" :grade="3" />
AdsMarker props
PropTypeDefaultDescription
labelstringRequired. Status is never colour alone.
tone'neutral' | 'ok' | 'warn' | 'bad'neutralEach tone has its own glyph.
grade1 | 2 | 3 | 4Severity as luminance; worse is brighter.
Source · ads/app/components/AdsMarker.vue
vue
<script setup lang="ts">
// A status marker: a glyph and a label, never a coloured dot on its own —
// status carried by colour alone fails a greyscale print and one reader in
// twelve. The colour comes from a closed set, so a caller cannot paint the
// chrome: `tone` for a state (ok, warn, bad, or neutral), or `grade` 1–4 for
// severity inside dense data, where three hues down four hundred rows is noise
// and luminance does the work instead (the worse, the brighter). Only the
// glyph takes the colour; the label stays in a text token at full contrast.
export type MarkerTone = 'neutral' | 'ok' | 'warn' | 'bad'

const props = withDefaults(defineProps<{ label: string; tone?: MarkerTone; grade?: 1 | 2 | 3 | 4 }>(), {
  tone: 'neutral',
  grade: undefined,
})

// Each tone has its own shape, so the state survives without the hue.
const glyph = computed(() => (props.grade ? null : { ok: 'check', warn: 'alert', bad: 'plus', neutral: null }[props.tone]))
</script>

<template>
  <span class="ads-marker" :class="grade ? `is-grade-${grade}` : `is-${tone}`">
    <span class="ads-marker__glyph" aria-hidden="true">
      <Icon v-if="glyph" :name="glyph" :size="13" :style="tone === 'bad' ? 'rotate: 45deg' : undefined" />
      <span v-else class="ads-marker__dot" />
    </span>
    <span class="ads-marker__label">{{ label }}</span>
    <slot />
  </span>
</template>

<style scoped>
.ads-marker {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2);
  font-size: 0.82rem;
  color: var(--text-soft);
}

.ads-marker__glyph {
  display: inline-grid;
  place-items: center;
  width: 14px;
  height: 14px;
  flex: none;
  color: var(--subdued);
}

.ads-marker__dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: currentColor;
}

.ads-marker.is-ok .ads-marker__glyph { color: var(--ok); }
.ads-marker.is-warn .ads-marker__glyph { color: var(--warn); }
.ads-marker.is-bad .ads-marker__glyph { color: var(--bad); }
.ads-marker.is-grade-1 .ads-marker__glyph { color: var(--grade-1); }
.ads-marker.is-grade-2 .ads-marker__glyph { color: var(--grade-2); }
.ads-marker.is-grade-3 .ads-marker__glyph { color: var(--grade-3); }
.ads-marker.is-grade-4 .ads-marker__glyph { color: var(--grade-4); }

.ads-marker__label {
  font-family: var(--font-label);
  font-size: 0.66rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}
</style>

AdsDirection

An arrow rotated toward a direction.

vue
<AdsDirection to="down" />
<AdsDirection to="up" label="Rising" />
AdsDirection props
PropTypeDefaultDescription
to'up' | 'down' | 'left' | 'right'right
labelstringGive one when the arrow carries meaning; otherwise it is decorative.
Source · ads/app/components/AdsDirection.vue
vue
<script setup lang="ts">
// A directional hint — an arrow rotated toward `to`. Decorative by default,
// since it usually sits beside words that already say where; give it a
// `label` when the arrow is the only thing carrying the meaning.
withDefaults(defineProps<{ to?: 'up' | 'down' | 'left' | 'right'; label?: string }>(), { to: 'right', label: '' })
</script>

<template>
  <span
    class="ads-direction"
    :class="`is-${to}`"
    :role="label ? 'img' : undefined"
    :aria-label="label || undefined"
    :aria-hidden="label ? undefined : 'true'"
  ><Icon name="arrow" :size="15" /></span>
</template>

<style scoped>
.ads-direction {
  display: inline-grid;
  place-items: center;
  color: var(--subdued);
}

.ads-direction.is-right :deep(svg) { rotate: 0deg; }
.ads-direction.is-down :deep(svg) { rotate: 90deg; }
.ads-direction.is-left :deep(svg) { rotate: 180deg; }
.ads-direction.is-up :deep(svg) { rotate: -90deg; }
</style>

AdsItem

A list row with an optional glyph; a link when given `to`.

Audience overview
4 issues to fix
vue
<AdsItem icon="chart">Audience overview</AdsItem>
<AdsItem icon="alert" to="/issues">4 issues to fix</AdsItem>
AdsItem props
PropTypeDefaultDescription
iconstring
tostringMakes the row a link; only links react to hover.
Source · ads/app/components/AdsItem.vue
vue
<script setup lang="ts">
// A list item: an optional glyph, then the body. It only answers the pointer
// when it goes somewhere — give it `to` and it becomes a real link that
// brightens on hover; without one it is plain text, because a row that lights
// up and does nothing is a promise the interface breaks. Rows in a list are
// separated by the parent, with a hairline, not by a box around each.
defineProps<{ icon?: string; iconSize?: number; to?: string }>()
</script>

<template>
  <NuxtLink v-if="to" :to="to" class="ads-item is-link">
    <Icon v-if="icon" :name="icon" :size="iconSize ?? 18" class="ads-item__icon" />
    <span class="ads-item__body"><slot /></span>
  </NuxtLink>
  <div v-else class="ads-item">
    <Icon v-if="icon" :name="icon" :size="iconSize ?? 18" class="ads-item__icon" />
    <span class="ads-item__body"><slot /></span>
  </div>
</template>

<style scoped>
.ads-item {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  padding: var(--sp-2) var(--sp-3);
  border-radius: var(--radius-sm);
  color: var(--text-soft);
  transition:
    color var(--dur-control) var(--ease),
    box-shadow var(--dur-control) var(--ease);
}

.ads-item.is-link:hover {
  color: var(--accent);
}

.ads-item.is-link:hover .ads-item__icon {
  color: var(--accent);
}

.ads-item.is-link:active {
  box-shadow: var(--soft-inset-sm);
}

.ads-item__icon {
  flex: none;
  color: var(--subdued);
  transition: color var(--dur-control) var(--ease);
}

.ads-item__body {
  min-width: 0;
}
</style>

AdsBubble

A chat bubble. Theirs is pressed in, yours is raised.

Hello from the studio.
On it — back in an hour.
vue
<AdsBubble>Hello from the studio.</AdsBubble>
<AdsBubble mine>On it — back in an hour.</AdsBubble>
Source · ads/app/components/AdsBubble.vue
vue
<script setup lang="ts">
// A chat bubble. Theirs is a well; `mine` stands proud of the page instead —
// depth and alignment tell the two voices apart. Not the accent: a thread
// would spend it on every other line, and the accent is for the one thing to
// click.
withDefaults(defineProps<{ mine?: boolean }>(), { mine: false })
</script>

<template>
  <div class="ads-bubble" :class="{ 'is-mine': mine }"><slot /></div>
</template>

<style scoped>
.ads-bubble {
  width: fit-content;
  max-width: 72ch;
  padding: var(--sp-2) var(--sp-4);
  border-radius: var(--radius-sm);
  background: var(--surface);
  box-shadow: var(--soft-inset-sm);
  font-size: 0.9rem;
  color: var(--text-soft);
}

.ads-bubble.is-mine {
  background: var(--raised);
  box-shadow: var(--soft-raised-sm);
  color: var(--text);
}
</style>

Inputs

AdsInputGroup

A field with a fixed addon either side.

https://.artelier.app
vue
<AdsInputGroup v-model="v" label="Subdomain" prefix="https://" suffix=".artelier.app" placeholder="studio" />
AdsInputGroup props
PropTypeDefaultDescription
v-modelstring
labelstringRequired. Visible field label. Units for a figure belong here, not in an addon.
prefixstring
suffixstring
hintstring
Source · ads/app/components/AdsInputGroup.vue
vue
<script setup lang="ts">
// A field with a fixed addon either side — a scheme, a domain, a path. The
// addon is part of the well, set in the data face and read out with the field,
// so "https://" is heard as well as seen. Not for units on a figure: those
// belong in the label (AdsNumber), so the digits stay in register.
const props = withDefaults(
  defineProps<{
    label: string
    prefix?: string
    suffix?: string
    placeholder?: string
    hint?: string
    modelValue?: string
    type?: string
    id?: string
  }>(),
  { prefix: '', suffix: '', placeholder: '', hint: '', modelValue: '', type: 'text', id: '' },
)
const emit = defineEmits<{ (e: 'update:modelValue', v: string): void }>()
const uid = useId()
const fieldId = computed(() => props.id || uid)
const describedBy = computed(
  () =>
    [props.prefix && `${fieldId.value}-pre`, props.suffix && `${fieldId.value}-suf`, props.hint && `${fieldId.value}-hint`]
      .filter(Boolean)
      .join(' ') || undefined,
)
</script>

<template>
  <div class="ads-input-group ads-field-group">
    <label class="ads-label" :for="fieldId">{{ label }}</label>
    <div class="ads-input-group__well">
      <span v-if="prefix" :id="`${fieldId}-pre`" class="ads-input-group__addon">{{ prefix }}</span>
      <input
        :id="fieldId"
        class="ads-input-group__input"
        :type="type"
        :value="modelValue"
        :placeholder="placeholder"
        :aria-describedby="describedBy"
        @input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
      >
      <span v-if="suffix" :id="`${fieldId}-suf`" class="ads-input-group__addon">{{ suffix }}</span>
    </div>
    <span v-if="hint" :id="`${fieldId}-hint`" class="ads-hint">{{ hint }}</span>
  </div>
</template>

<style scoped>
.ads-input-group__well {
  display: flex;
  align-items: center;
  border-radius: var(--radius-sm);
  background: var(--surface);
  box-shadow: var(--soft-inset);
  transition: box-shadow var(--dur-control) var(--ease);
}

/* The input inside draws no ring of its own, so the whole well takes it. */
.ads-input-group__well:focus-within {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.ads-input-group__addon {
  flex: none;
  padding: 0 var(--sp-3);
  font-family: var(--mono);
  font-size: 0.82rem;
  color: var(--subdued);
  white-space: nowrap;
}

.ads-input-group__addon:first-child {
  padding-right: 0;
}

.ads-input-group__addon:last-child {
  padding-left: 0;
}

.ads-input-group__input {
  flex: 1;
  min-width: 0;
  padding: 11px var(--sp-3);
  border: 0;
  background: transparent;
  color: var(--text);
  font: inherit;
  font-size: 0.92rem;
}

.ads-input-group__input:focus,
.ads-input-group__input:focus-visible {
  outline: none;
}

.ads-input-group__input::placeholder {
  color: var(--subdued);
}
</style>

AdsInputOtp

A one-time code, pasted or typed.

Sign-in code
vue
<AdsInputOtp v-model="code" label="Sign-in code" :length="6" />
AdsInputOtp props
PropTypeDefaultDescription
v-modelstring
labelstringOne-time code
lengthnumber6
Source · ads/app/components/AdsInputOtp.vue
vue
<script setup lang="ts">
// A one-time code: a row of single-digit wells, in the data face so the digits
// line up with the code in the message they were copied from. It behaves like
// one field — typing advances, backspace goes back, arrows move, and pasting
// the whole code fills every box. The browser can offer the code from an SMS.
const props = withDefaults(defineProps<{ length?: number; modelValue?: string; label?: string }>(), {
  length: 6,
  modelValue: '',
  label: 'One-time code',
})
const emit = defineEmits<{ (e: 'update:modelValue', v: string): void }>()
const uid = useId()
const root = ref<HTMLElement>()
const boxes = computed(() => Array.from({ length: props.length }, (_, i) => props.modelValue[i] ?? ''))

function box(i: number) {
  return root.value?.querySelectorAll<HTMLInputElement>('input')[i]
}

function write(chars: string[]) {
  emit('update:modelValue', chars.join('').slice(0, props.length))
}

function onInput(i: number, e: Event) {
  const el = e.target as HTMLInputElement
  const digits = el.value.replace(/\D/g, '')
  const next = boxes.value.slice()
  if (!digits) {
    next[i] = ''
    el.value = ''
    write(next)
    return
  }
  // More than one digit at once is autofill or a paste: spread it forward.
  for (let k = 0; k < digits.length && i + k < props.length; k++) next[i + k] = digits[k]!
  write(next)
  el.value = next[i]!
  box(Math.min(i + digits.length, props.length - 1))?.focus()
}

function onKeydown(i: number, e: KeyboardEvent) {
  if (e.key === 'Backspace' && !boxes.value[i] && i > 0) {
    e.preventDefault()
    const next = boxes.value.slice()
    next[i - 1] = ''
    write(next)
    box(i - 1)?.focus()
  } else if (e.key === 'ArrowLeft' && i > 0) {
    e.preventDefault()
    box(i - 1)?.focus()
  } else if (e.key === 'ArrowRight' && i < props.length - 1) {
    e.preventDefault()
    box(i + 1)?.focus()
  }
}

function onPaste(i: number, e: ClipboardEvent) {
  const digits = (e.clipboardData?.getData('text') ?? '').replace(/\D/g, '')
  if (!digits) return
  e.preventDefault()
  const next = boxes.value.slice()
  for (let k = 0; k < digits.length && i + k < props.length; k++) next[i + k] = digits[k]!
  write(next)
  box(Math.min(i + digits.length, props.length - 1))?.focus()
}
</script>

<template>
  <div class="ads-input-otp ads-field-group">
    <span :id="`${uid}-label`" class="ads-label">{{ label }}</span>
    <div ref="root" class="ads-input-otp__boxes" role="group" :aria-labelledby="`${uid}-label`">
      <input
        v-for="(v, i) in boxes"
        :key="i"
        class="ads-input-otp__box num"
        inputmode="numeric"
        pattern="[0-9]*"
        :autocomplete="i === 0 ? 'one-time-code' : 'off'"
        :value="v"
        :aria-label="`Digit ${i + 1} of ${length}`"
        @input="onInput(i, $event)"
        @keydown="onKeydown(i, $event)"
        @paste="onPaste(i, $event)"
        @focus="($event.target as HTMLInputElement).select()"
      >
    </div>
  </div>
</template>

<style scoped>
.ads-input-otp__boxes {
  display: flex;
  gap: var(--sp-2);
}

.ads-input-otp__box {
  width: 44px;
  height: 48px;
  border: 0;
  text-align: center;
  border-radius: var(--radius-sm);
  background: var(--surface);
  box-shadow: var(--soft-inset-sm);
  color: var(--text);
  font-size: 1.1rem;
}

.ads-input-otp__box:focus {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
</style>

AdsNativeSelect

The browser's select, styled as a well.

vue
<AdsNativeSelect
  v-model="v"
  label="Device"
  :options="[{ label: 'Desktop', value: 'desktop' }, { label: 'Mobile', value: 'mobile' }]"
/>
AdsNativeSelect props
PropTypeDefaultDescription
v-modelstring | number
labelstringRequired. Visible field label (replaces aria-label).
options{ label, value }[]
placeholderstringChoose…
hintstring
Source · ads/app/components/AdsNativeSelect.vue
vue
<script setup lang="ts">
// The browser's own select, styled as a well (the `.ads-field` shape, chevron
// painted into its background). Reach for it before AdsSelect: it is already
// accessible, keyboard-complete and native on a phone. A visible label always —
// the placeholder option disappears exactly when it is needed.
const props = withDefaults(
  defineProps<{
    label: string
    modelValue?: string | number
    options?: { label: string; value: string | number }[]
    placeholder?: string
    hint?: string
    id?: string
  }>(),
  { modelValue: '', options: () => [], placeholder: 'Choose…', hint: '', id: '' },
)
const emit = defineEmits<{ (e: 'update:modelValue', v: string | number): void }>()
const uid = useId()
const fieldId = computed(() => props.id || uid)
</script>

<template>
  <div class="ads-native-select ads-field-group">
    <label class="ads-label" :for="fieldId">{{ label }}</label>
    <select
      :id="fieldId"
      class="ads-field"
      :value="modelValue"
      :aria-describedby="hint ? `${fieldId}-hint` : undefined"
      @change="emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
    >
      <option v-if="placeholder" value="" disabled>{{ placeholder }}</option>
      <option v-for="o in options" :key="o.value" :value="o.value">{{ o.label }}</option>
    </select>
    <span v-if="hint" :id="`${fieldId}-hint`" class="ads-hint">{{ hint }}</span>
  </div>
</template>

AdsSelect

A custom select, for when an option needs more than text.

Billing
vue
<AdsSelect
  v-model="v"
  label="Billing"
  :options="[{ label: 'Monthly', value: 'monthly' }, { label: 'Yearly', value: 'yearly' }]"
/>
AdsSelect props
PropTypeDefaultDescription
v-modelstring | number
labelstringRequired. Visible field label.
options{ label, value }[]
placeholderstringChoose…
hintstring
Source · ads/app/components/AdsSelect.vue
vue
<script setup lang="ts">
// A custom select, for when an option needs more than text. Prefer
// AdsNativeSelect — this one has to rebuild what the browser gives free, so it
// follows the select-only combobox pattern to the letter: the trigger is a
// well with a visible label, arrows move, typing jumps, Enter picks, Escape
// closes and focus stays on the trigger. The chosen option is marked with a
// tick, not an accent fill — the accent is spent elsewhere on the screen.
const props = withDefaults(
  defineProps<{
    label: string
    options?: { label: string; value: string | number }[]
    modelValue?: string | number
    placeholder?: string
    hint?: string
  }>(),
  { options: () => [], modelValue: '', placeholder: 'Choose…', hint: '' },
)
const emit = defineEmits<{ (e: 'update:modelValue', v: string | number): void }>()

const uid = useId()
const root = ref<HTMLElement>()
const listEl = ref<HTMLElement>()
const open = ref(false)
const active = ref(-1)
let typed = ''
let typedTimer: ReturnType<typeof setTimeout> | undefined

const selectedIndex = computed(() => props.options.findIndex((o) => o.value === props.modelValue))
const selectedLabel = computed(() => props.options[selectedIndex.value]?.label)

function show() {
  open.value = true
  active.value = Math.max(selectedIndex.value, 0)
  nextTick(scrollActive)
}

function hide() {
  open.value = false
}

function pick(i: number) {
  const o = props.options[i]
  if (o) emit('update:modelValue', o.value)
  hide()
}

function move(i: number) {
  active.value = Math.min(Math.max(i, 0), props.options.length - 1)
  nextTick(scrollActive)
}

function scrollActive() {
  listEl.value?.querySelector<HTMLElement>(`#${CSS.escape(`${uid}-opt-${active.value}`)}`)?.scrollIntoView({ block: 'nearest' })
}

function typeahead(ch: string) {
  typed += ch.toLowerCase()
  clearTimeout(typedTimer)
  typedTimer = setTimeout(() => (typed = ''), 500)
  const start = open.value ? active.value + (typed.length === 1 ? 1 : 0) : 0
  const n = props.options.length
  for (let k = 0; k < n; k++) {
    const i = (start + k) % n
    if (props.options[i]!.label.toLowerCase().startsWith(typed)) {
      if (!open.value) show()
      move(i)
      return
    }
  }
}

function onKeydown(e: KeyboardEvent) {
  const last = props.options.length - 1
  switch (e.key) {
    case 'ArrowDown':
      e.preventDefault()
      open.value ? move(active.value + 1) : show()
      break
    case 'ArrowUp':
      e.preventDefault()
      open.value ? move(active.value - 1) : show()
      break
    case 'Home':
      if (open.value) { e.preventDefault(); move(0) }
      break
    case 'End':
      if (open.value) { e.preventDefault(); move(last) }
      break
    case 'Enter':
    case ' ':
      e.preventDefault()
      open.value ? pick(active.value) : show()
      break
    case 'Escape':
      if (open.value) { e.preventDefault(); hide() }
      break
    case 'Tab':
      if (open.value) pick(active.value)
      break
    default:
      if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) typeahead(e.key)
  }
}

function onFocusout(e: FocusEvent) {
  if (!root.value?.contains(e.relatedTarget as Node)) hide()
}

onBeforeUnmount(() => clearTimeout(typedTimer))
</script>

<template>
  <div ref="root" class="ads-select ads-field-group" @focusout="onFocusout">
    <span :id="`${uid}-label`" class="ads-label">{{ label }}</span>
    <div class="ads-select__anchor">
      <button
        type="button"
        class="ads-select__trigger ads-field"
        role="combobox"
        aria-haspopup="listbox"
        :aria-expanded="open"
        :aria-controls="`${uid}-list`"
        :aria-labelledby="`${uid}-label ${uid}-value`"
        :aria-activedescendant="open && active >= 0 ? `${uid}-opt-${active}` : undefined"
        :aria-describedby="hint ? `${uid}-hint` : undefined"
        @click="open ? hide() : show()"
        @keydown="onKeydown"
      >
        <span :id="`${uid}-value`" class="ads-select__value" :class="{ 'is-placeholder': !selectedLabel }">
          {{ selectedLabel ?? placeholder }}
        </span>
        <svg class="ads-select__chevron" viewBox="0 0 12 12" width="12" height="12" aria-hidden="true">
          <path d="M3 4.5l3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
        </svg>
      </button>
      <ul
        v-show="open"
        :id="`${uid}-list`"
        ref="listEl"
        class="ads-select__list"
        role="listbox"
        :aria-labelledby="`${uid}-label`"
        tabindex="-1"
      >
        <li
          v-for="(o, i) in options"
          :id="`${uid}-opt-${i}`"
          :key="o.value"
          class="ads-select__option"
          :class="{ 'is-active': i === active }"
          role="option"
          :aria-selected="o.value === modelValue"
          @mousedown.prevent
          @mousemove="active = i"
          @click="pick(i)"
        >
          <span>{{ o.label }}</span>
          <Icon v-if="o.value === modelValue" name="check" :size="14" class="ads-select__tick" />
        </li>
      </ul>
    </div>
    <span v-if="hint" :id="`${uid}-hint`" class="ads-hint">{{ hint }}</span>
  </div>
</template>

<style scoped>
.ads-select {
  min-width: 180px;
}

.ads-select__anchor {
  position: relative;
}

.ads-select__trigger {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-2);
  text-align: left;
  cursor: pointer;
}

.ads-select__value.is-placeholder {
  color: var(--subdued);
}

.ads-select__chevron {
  flex: none;
  color: var(--subdued);
  transition: transform var(--dur-control) var(--ease);
}

.ads-select__trigger[aria-expanded='true'] .ads-select__chevron {
  transform: rotate(180deg);
}

.ads-select__list {
  position: absolute;
  top: calc(100% + 6px);
  right: 0;
  left: 0;
  z-index: 20;
  display: grid;
  gap: 2px;
  max-height: 260px;
  margin: 0;
  padding: 6px;
  overflow-y: auto;
  list-style: none;
  border-radius: var(--radius-sm);
  background: var(--elevated);
  box-shadow: var(--soft-raised-lg);
}

.ads-select__option {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-2);
  padding: 9px var(--sp-3);
  border-radius: 9px;
  color: var(--text-soft);
  font-size: 0.9rem;
  cursor: pointer;
  transition: color var(--dur-control) var(--ease), background var(--dur-control) var(--ease);
}

.ads-select__option.is-active {
  background: var(--hover);
  color: var(--accent);
}

.ads-select__option[aria-selected='true'] {
  color: var(--text);
}

.ads-select__tick {
  flex: none;
  color: var(--text);
}
</style>

AdsCombobox

A field that filters a list as you type.

vue
<AdsCombobox
  v-model="v"
  label="Project"
  :options="[{ label: 'CrawlStat', value: 'crawlstat' }, { label: 'Artelier', value: 'artelier' }]"
/>
AdsCombobox props
PropTypeDefaultDescription
v-modelstring | number
labelstringRequired. Visible field label.
options{ label, value }[]
placeholderstringType to search
hintstring
Source · ads/app/components/AdsCombobox.vue
vue
<script setup lang="ts">
// A field that filters a list as you type — for a choice among too many
// options to scan. It is a real combobox: the list is announced, arrows move
// through it without leaving the field, Enter picks, Escape closes (and a
// second Escape clears). An empty result says so instead of vanishing.
const props = withDefaults(
  defineProps<{
    label: string
    options?: { label: string; value: string | number }[]
    modelValue?: string | number
    placeholder?: string
    hint?: string
  }>(),
  { options: () => [], modelValue: '', placeholder: 'Type to search', hint: '' },
)
const emit = defineEmits<{ (e: 'update:modelValue', v: string | number): void }>()

const uid = useId()
const listEl = ref<HTMLElement>()
const query = ref(props.options.find((o) => o.value === props.modelValue)?.label ?? '')
const open = ref(false)
const active = ref(-1)

watch(
  () => props.modelValue,
  (v) => {
    const o = props.options.find((x) => x.value === v)
    if (o) query.value = o.label
  },
)

const filtered = computed(() => {
  const q = query.value.trim().toLowerCase()
  const chosen = props.options.find((o) => o.value === props.modelValue)
  // Showing the whole list again once a choice is in the field.
  if (!q || chosen?.label.toLowerCase() === q) return props.options
  return props.options.filter((o) => o.label.toLowerCase().includes(q))
})

function show() {
  open.value = true
}

function pick(i: number) {
  const o = filtered.value[i]
  if (!o) return
  emit('update:modelValue', o.value)
  query.value = o.label
  open.value = false
  active.value = -1
}

function move(i: number) {
  if (!filtered.value.length) return
  open.value = true
  active.value = (i + filtered.value.length) % filtered.value.length
  nextTick(() =>
    listEl.value?.querySelector<HTMLElement>(`#${CSS.escape(`${uid}-opt-${active.value}`)}`)?.scrollIntoView({ block: 'nearest' }),
  )
}

function onInput() {
  open.value = true
  active.value = -1
}

function onKeydown(e: KeyboardEvent) {
  switch (e.key) {
    case 'ArrowDown':
      e.preventDefault()
      move(active.value + 1)
      break
    case 'ArrowUp':
      e.preventDefault()
      move(active.value - 1)
      break
    case 'Enter':
      if (open.value && active.value >= 0) {
        e.preventDefault()
        pick(active.value)
      }
      break
    case 'Escape':
      e.preventDefault()
      if (open.value) open.value = false
      else query.value = ''
      active.value = -1
      break
  }
}
</script>

<template>
  <div class="ads-combobox ads-field-group">
    <label class="ads-label" :for="`${uid}-input`">{{ label }}</label>
    <div class="ads-combobox__anchor">
      <input
        :id="`${uid}-input`"
        v-model="query"
        class="ads-combobox__input ads-field"
        type="text"
        role="combobox"
        autocomplete="off"
        aria-autocomplete="list"
        :aria-expanded="open"
        :aria-controls="`${uid}-list`"
        :aria-activedescendant="open && active >= 0 ? `${uid}-opt-${active}` : undefined"
        :aria-describedby="hint ? `${uid}-hint` : undefined"
        :placeholder="placeholder"
        @focus="show"
        @click="show"
        @blur="open = false"
        @input="onInput"
        @keydown="onKeydown"
      >
      <ul
        v-show="open"
        :id="`${uid}-list`"
        ref="listEl"
        class="ads-combobox__list"
        role="listbox"
        :aria-labelledby="`${uid}-input`"
      >
        <li
          v-for="(o, i) in filtered"
          :id="`${uid}-opt-${i}`"
          :key="o.value"
          class="ads-combobox__option"
          :class="{ 'is-active': i === active }"
          role="option"
          :aria-selected="o.value === modelValue"
          @mousedown.prevent="pick(i)"
          @mousemove="active = i"
        >
          <span>{{ o.label }}</span>
          <Icon v-if="o.value === modelValue" name="check" :size="14" class="ads-combobox__tick" />
        </li>
        <li v-if="!filtered.length" class="ads-combobox__empty" role="presentation">No matches</li>
      </ul>
    </div>
    <span v-if="hint" :id="`${uid}-hint`" class="ads-hint">{{ hint }}</span>
  </div>
</template>

<style scoped>
.ads-combobox__anchor {
  position: relative;
}

.ads-combobox__list {
  position: absolute;
  top: calc(100% + 6px);
  right: 0;
  left: 0;
  z-index: 20;
  display: grid;
  gap: 2px;
  max-height: 240px;
  margin: 0;
  padding: 6px;
  overflow-y: auto;
  list-style: none;
  border-radius: var(--radius-sm);
  background: var(--elevated);
  box-shadow: var(--soft-raised-lg);
}

.ads-combobox__option {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-2);
  padding: 9px var(--sp-3);
  border-radius: 9px;
  color: var(--text-soft);
  font-size: 0.9rem;
  cursor: pointer;
  transition: color var(--dur-control) var(--ease), background var(--dur-control) var(--ease);
}

.ads-combobox__option.is-active {
  background: var(--hover);
  color: var(--accent);
}

.ads-combobox__option[aria-selected='true'] {
  color: var(--text);
}

.ads-combobox__tick {
  flex: none;
}

.ads-combobox__empty {
  padding: 9px var(--sp-3);
  font-size: 0.86rem;
  color: var(--subdued);
}
</style>

AdsAttachment

A file attachment chip.

report.pdf1.2 MBtokens.json4 KB
vue
<AdsAttachment name="report.pdf" size="1.2 MB" @remove="…" />
Source · ads/app/components/AdsAttachment.vue
vue
<script setup lang="ts">
// A file attachment chip: the type, the name, the size, and a way to take it
// off again. The type is the file's own extension in the data face rather than
// a generic glyph — it is the one fact about the file a reader scans for. The
// size is a figure, so it is monospaced; pass it with its unit ("1.2 MB").
const props = withDefaults(defineProps<{ name: string; size?: string; removable?: boolean }>(), {
  size: '',
  removable: true,
})
const emit = defineEmits<{ (e: 'remove'): void }>()

const ext = computed(() => {
  const dot = props.name.lastIndexOf('.')
  return dot > 0 ? props.name.slice(dot + 1, dot + 5).toUpperCase() : 'FILE'
})
</script>

<template>
  <span class="ads-attachment">
    <span class="ads-attachment__type" aria-hidden="true">{{ ext }}</span>
    <span class="ads-attachment__text">
      <span class="ads-attachment__name">{{ name }}</span>
      <span v-if="size" class="ads-attachment__size num">{{ size }}</span>
    </span>
    <button
      v-if="removable"
      type="button"
      class="ads-attachment__remove"
      :aria-label="`Remove ${name}`"
      @click="emit('remove')"
    >
      <Icon name="plus" :size="14" style="rotate: 45deg" />
    </button>
  </span>
</template>

<style scoped>
.ads-attachment {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-3);
  padding: var(--sp-2) var(--sp-2) var(--sp-2) var(--sp-3);
  border-radius: var(--radius-sm);
  background: var(--surface);
  box-shadow: var(--soft-inset-sm);
  color: var(--text-soft);
}

.ads-attachment__type {
  display: grid;
  place-items: center;
  min-width: 34px;
  height: 30px;
  padding: 0 var(--sp-1);
  flex: none;
  border-radius: var(--radius-sm);
  background: var(--raised);
  box-shadow: var(--soft-raised-sm);
  font-family: var(--mono);
  font-size: 0.6rem;
  letter-spacing: 0.04em;
  color: var(--subdued);
}

.ads-attachment__text {
  display: grid;
  gap: 1px;
  min-width: 0;
}

.ads-attachment__name {
  font-size: 0.85rem;
  color: var(--text);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.ads-attachment__size {
  font-size: 0.7rem;
  color: var(--subdued);
}

.ads-attachment__remove {
  display: grid;
  place-items: center;
  width: 24px;
  height: 24px;
  flex: none;
  border-radius: 50%;
  color: var(--subdued);
  transition:
    color var(--dur-control) var(--ease),
    box-shadow var(--dur-control) var(--ease);
}

.ads-attachment__remove:hover {
  color: var(--accent);
}

.ads-attachment__remove:active {
  box-shadow: var(--soft-inset-sm);
}
</style>

Overlays & navigation

AdsHoverCard

Extra context on hover or focus; Escape dismisses it.

vue
<AdsHoverCard>
  <template #trigger><AdsButton>Hover me</AdsButton></template>
  The card that appears beside it.
</AdsHoverCard>
Source · ads/app/components/AdsHoverCard.vue
vue
<script setup lang="ts">
// Extra context beside a name or a link, on hover or keyboard focus. It is
// supplementary — nothing in it may be the only way to reach something — so
// it waits a beat before opening, lingers long enough to move the pointer
// into it, and Escape dismisses it without moving focus.
const props = withDefaults(defineProps<{ openDelay?: number; closeDelay?: number }>(), {
  openDelay: 300,
  closeDelay: 200,
})
const uid = useId()
const open = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined

function later(next: boolean, ms: number) {
  clearTimeout(timer)
  timer = setTimeout(() => (open.value = next), ms)
}

function onKeydown(e: KeyboardEvent) {
  if (e.key === 'Escape' && open.value) {
    e.stopPropagation()
    clearTimeout(timer)
    open.value = false
  }
}

onBeforeUnmount(() => clearTimeout(timer))
</script>

<template>
  <div
    class="ads-hover-card"
    @mouseenter="later(true, props.openDelay)"
    @mouseleave="later(false, props.closeDelay)"
    @focusin="later(true, 0)"
    @focusout="later(false, props.closeDelay)"
    @keydown="onKeydown"
  >
    <span class="ads-hover-card__trigger" :aria-describedby="open ? uid : undefined"><slot name="trigger" /></span>
    <div :id="uid" class="ads-hover-card__card" :class="{ 'is-open': open }" role="tooltip">
      <slot />
    </div>
  </div>
</template>

<style scoped>
.ads-hover-card {
  position: relative;
  display: inline-block;
}

.ads-hover-card__card {
  position: absolute;
  top: calc(100% + var(--sp-2));
  left: 0;
  z-index: 20;
  min-width: 220px;
  padding: var(--sp-3) var(--sp-4);
  border-radius: var(--radius-sm);
  background: var(--elevated);
  box-shadow: var(--soft-raised-lg);
  opacity: 0;
  visibility: hidden;
  transform: translateY(-4px);
  transition:
    opacity var(--dur-control) var(--ease),
    transform var(--dur-control) var(--ease),
    visibility var(--dur-control);
}

.ads-hover-card__card.is-open {
  opacity: 1;
  visibility: visible;
  transform: none;
}
</style>

AdsSheet

A modal panel from an edge, for a task that needs room. Not for row detail — use a drawer.

vue
<AdsSheet v-model:open="open" side="right" title="Filters">…</AdsSheet>
AdsSheet props
PropTypeDefaultDescription
v-model:openbooleanfalse
titlestringRequired. Names the dialog.
side'left' | 'right' | 'top' | 'bottom'right
Source · ads/app/components/AdsSheet.vue
vue
<script setup lang="ts">
// A panel that slides in from an edge for a task that needs more room than a
// popover — a filter set, a settings group. It is a modal dialog: it takes
// focus, keeps Tab inside, closes on Escape, the close button or the scrim,
// and hands focus back to whatever opened it. Not for a row's detail — that is
// a drawer, so the list stays in view.
const props = withDefaults(defineProps<{ title: string; side?: 'left' | 'right' | 'top' | 'bottom' }>(), {
  side: 'right',
})
const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ (e: 'close'): void }>()

const uid = useId()
const panel = ref<HTMLElement>()
let returnTo: HTMLElement | null = null
let prevOverflow = ''

const FOCUSABLE = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'

function close() {
  open.value = false
  emit('close')
}

function onKeydown(e: KeyboardEvent) {
  if (e.key === 'Escape') {
    e.preventDefault()
    close()
    return
  }
  if (e.key !== 'Tab' || !panel.value) return
  const items = Array.from(panel.value.querySelectorAll<HTMLElement>(FOCUSABLE))
  if (!items.length) return
  const first = items[0]!
  const last = items[items.length - 1]!
  if (e.shiftKey && document.activeElement === first) {
    e.preventDefault()
    last.focus()
  } else if (!e.shiftKey && document.activeElement === last) {
    e.preventDefault()
    first.focus()
  }
}

watch(
  open,
  async (isOpen) => {
    if (!false) return
    if (isOpen) {
      returnTo = document.activeElement as HTMLElement | null
      prevOverflow = document.body.style.overflow
      document.body.style.overflow = 'hidden'
      await nextTick()
      // First field if there is one, otherwise the close button.
      const first = panel.value?.querySelector<HTMLElement>(`.ads-sheet__body :is(${FOCUSABLE})`)
      ;(first ?? panel.value?.querySelector<HTMLElement>('.ads-sheet__close'))?.focus()
    } else {
      document.body.style.overflow = prevOverflow
      returnTo?.focus?.()
      returnTo = null
    }
  },
  { immediate: true },
)

onBeforeUnmount(() => {
  if (false && open.value) document.body.style.overflow = prevOverflow
})
</script>

<template>
  <Teleport to="body">
    <div v-if="open" class="ads-sheet" @keydown="onKeydown">
      <div class="ads-sheet__scrim" aria-hidden="true" @click="close" />
      <div
        ref="panel"
        class="ads-sheet__panel"
        :class="`is-${props.side}`"
        role="dialog"
        aria-modal="true"
        :aria-labelledby="`${uid}-title`"
      >
        <header class="ads-sheet__head">
          <h2 :id="`${uid}-title`" class="ads-sheet__title">{{ title }}</h2>
          <button class="ads-sheet__close ads-icon-button" type="button" aria-label="Close sheet" @click="close">
            <Icon name="plus" :size="16" style="transform: rotate(45deg)" />
          </button>
        </header>
        <div class="ads-sheet__body"><slot /></div>
      </div>
    </div>
  </Teleport>
</template>

<style scoped>
.ads-sheet {
  position: fixed;
  inset: 0;
  z-index: 50;
}

.ads-sheet__scrim {
  position: absolute;
  inset: 0;
  background: rgb(0 0 0 / 0.4);
  backdrop-filter: blur(2px);
}

.ads-sheet__panel {
  position: absolute;
  display: grid;
  grid-template-rows: auto minmax(0, 1fr);
  gap: var(--sp-4);
  max-width: 92vw;
  max-height: 100dvh;
  padding: var(--sp-5);
  background: var(--raised);
  box-shadow: var(--soft-raised-lg);
}

.ads-sheet__panel.is-right {
  top: 0;
  right: 0;
  bottom: 0;
  width: min(420px, 92vw);
  border-radius: var(--radius-lg) 0 0 var(--radius-lg);
  animation: ads-sheet-in-right var(--dur-move) var(--ease);
}

.ads-sheet__panel.is-left {
  top: 0;
  bottom: 0;
  left: 0;
  width: min(420px, 92vw);
  border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
  animation: ads-sheet-in-left var(--dur-move) var(--ease);
}

.ads-sheet__panel.is-top {
  top: 0;
  right: 0;
  left: 0;
  max-width: none;
  max-height: 80dvh;
  border-radius: 0 0 var(--radius-lg) var(--radius-lg);
  animation: ads-sheet-in-top var(--dur-move) var(--ease);
}

.ads-sheet__panel.is-bottom {
  right: 0;
  bottom: 0;
  left: 0;
  max-width: none;
  max-height: 80dvh;
  border-radius: var(--radius-lg) var(--radius-lg) 0 0;
  animation: ads-sheet-in-bottom var(--dur-move) var(--ease);
}

.ads-sheet__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-3);
}

.ads-sheet__title {
  margin: 0;
  font-family: var(--font-display);
  font-size: 1.2rem;
  font-weight: 600;
  color: var(--text);
}

.ads-sheet__body {
  min-height: 0;
  overflow-y: auto;
}

@keyframes ads-sheet-in-right {
  from { transform: translateX(100%); }
}

@keyframes ads-sheet-in-left {
  from { transform: translateX(-100%); }
}

@keyframes ads-sheet-in-top {
  from { transform: translateY(-100%); }
}

@keyframes ads-sheet-in-bottom {
  from { transform: translateY(100%); }
}
</style>

AdsContextMenu

A menu on right-click or Shift+F10, destructive items last.

Right-click this block, or focus it and press Shift+F10.

Press Shift+F10 for actions
vue
<AdsContextMenu
  label="Project actions"
  :items="[
    { label: 'Rename', shortcut: 'F2', action: rename },
    { label: 'Duplicate', shortcut: '⌘D', action: duplicate },
    { label: 'Delete project', destructive: true, action: confirmDelete },
  ]"
>
  Right-click me, or focus me and press Shift+F10
</AdsContextMenu>
AdsContextMenu props
PropTypeDefaultDescription
items{ label, action?, shortcut?, destructive?, icon? }[]Destructive items are moved last, below a rule.
labelstringContext menu
Source · ads/app/components/AdsContextMenu.vue
vue
<script setup lang="ts">
// A menu opened on the thing it acts on — right-click, or the Menu key /
// Shift+F10 when the region has focus, because a pointer-only menu is a
// locked door. It follows the menu rules: shortcuts shown on the right,
// destructive items last and below a rule, and never in the accent. Arrow
// keys move, Escape closes and focus goes back to where it was.
export type ContextMenuItem = {
  label: string
  action?: () => void
  shortcut?: string
  destructive?: boolean
  icon?: string
}
const props = withDefaults(defineProps<{ items: ContextMenuItem[]; label?: string }>(), { label: 'Context menu' })

const uid = useId()
const region = ref<HTMLElement>()
const menu = ref<HTMLElement>()
const open = ref(false)
const pos = ref({ x: 0, y: 0 })
let returnTo: HTMLElement | null = null

const safe = computed(() => props.items.filter((i) => !i.destructive))
const risky = computed(() => props.items.filter((i) => i.destructive))

function buttons() {
  return Array.from(menu.value?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? [])
}

async function showAt(x: number, y: number) {
  returnTo = document.activeElement as HTMLElement | null
  pos.value = { x, y }
  open.value = true
  await nextTick()
  // Keep the whole menu on screen.
  const r = menu.value?.getBoundingClientRect()
  if (r) {
    pos.value = {
      x: Math.max(8, Math.min(x, window.innerWidth - r.width - 8)),
      y: Math.max(8, Math.min(y, window.innerHeight - r.height - 8)),
    }
  }
  buttons()[0]?.focus()
}

function onContextmenu(e: MouseEvent) {
  e.preventDefault()
  showAt(e.clientX, e.clientY)
}

function onRegionKeydown(e: KeyboardEvent) {
  if (e.key === 'ContextMenu' || (e.key === 'F10' && e.shiftKey)) {
    e.preventDefault()
    const r = region.value!.getBoundingClientRect()
    showAt(r.left + 16, r.top + 16)
  }
}

function close(restore = true) {
  open.value = false
  if (restore) returnTo?.focus?.()
  returnTo = null
}

function pick(item: ContextMenuItem) {
  close()
  item.action?.()
}

function onMenuKeydown(e: KeyboardEvent) {
  const list = buttons()
  const i = list.indexOf(document.activeElement as HTMLButtonElement)
  const go = (n: number) => list[(n + list.length) % list.length]?.focus()
  switch (e.key) {
    case 'ArrowDown': e.preventDefault(); go(i + 1); break
    case 'ArrowUp': e.preventDefault(); go(i - 1); break
    case 'Home': e.preventDefault(); go(0); break
    case 'End': e.preventDefault(); go(list.length - 1); break
    case 'Escape': e.preventDefault(); close(); break
    case 'Tab': close(false); break
  }
}
</script>

<template>
  <div
    ref="region"
    class="ads-context-menu"
    tabindex="0"
    :aria-describedby="`${uid}-hint`"
    @contextmenu="onContextmenu"
    @keydown="onRegionKeydown"
  >
    <slot />
    <span :id="`${uid}-hint`" class="sr-only">Press Shift+F10 for actions</span>
    <Teleport to="body">
      <div v-if="open" class="ads-context-menu__layer" @click="close()" @contextmenu.prevent="close()">
        <div
          ref="menu"
          class="ads-context-menu__menu"
          role="menu"
          :aria-label="label"
          :style="{ left: `${pos.x}px`, top: `${pos.y}px` }"
          @click.stop
          @keydown="onMenuKeydown"
        >
          <button
            v-for="(it, i) in safe"
            :key="`s${i}`"
            type="button"
            role="menuitem"
            tabindex="-1"
            class="ads-context-menu__item"
            @click="pick(it)"
          >
            <Icon v-if="it.icon" :name="it.icon" :size="15" />
            <span>{{ it.label }}</span>
            <kbd v-if="it.shortcut" class="ads-context-menu__shortcut">{{ it.shortcut }}</kbd>
          </button>
          <hr v-if="risky.length && safe.length" class="ads-context-menu__rule" role="separator">
          <button
            v-for="(it, i) in risky"
            :key="`d${i}`"
            type="button"
            role="menuitem"
            tabindex="-1"
            class="ads-context-menu__item"
            @click="pick(it)"
          >
            <Icon v-if="it.icon" :name="it.icon" :size="15" />
            <span>{{ it.label }}</span>
            <kbd v-if="it.shortcut" class="ads-context-menu__shortcut">{{ it.shortcut }}</kbd>
          </button>
        </div>
      </div>
    </Teleport>
  </div>
</template>

<style scoped>
.ads-context-menu {
  border-radius: var(--radius-sm);
}

.ads-context-menu__layer {
  position: fixed;
  inset: 0;
  z-index: 60;
}

.ads-context-menu__menu {
  position: fixed;
  display: grid;
  gap: 2px;
  min-width: 190px;
  padding: 6px;
  border-radius: var(--radius-sm);
  background: var(--elevated);
  box-shadow: var(--soft-raised-lg);
  animation: ads-context-menu-in var(--dur-control) var(--ease);
}

.ads-context-menu__item {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  width: 100%;
  padding: var(--sp-2) 10px;
  border-radius: 8px;
  color: var(--text-soft);
  font-size: 0.86rem;
  text-align: left;
  transition: color var(--dur-control) var(--ease), background var(--dur-control) var(--ease);
}

.ads-context-menu__item:hover,
.ads-context-menu__item:focus-visible {
  background: var(--hover);
  color: var(--accent);
}

.ads-context-menu__item:active {
  box-shadow: var(--soft-inset-sm);
}

.ads-context-menu__shortcut {
  margin-left: auto;
  font-family: var(--mono);
  font-size: 0.7rem;
  color: var(--subdued);
}

.ads-context-menu__rule {
  height: 1px;
  margin: var(--sp-1) 0;
  border: 0;
  background: var(--line);
}

@keyframes ads-context-menu-in {
  from {
    opacity: 0;
    transform: scale(0.97);
  }
}
</style>

AdsMenubar

An application menu bar, keyboard-complete.

vue
<AdsMenubar
  label="Editor"
  :items="[
    { label: 'File', children: [
      { label: 'New file', shortcut: '⌘N', action: create },
      { label: 'Delete file', destructive: true, action: confirmDelete },
    ] },
    { label: 'Help', to: '/help' },
  ]"
/>
AdsMenubar props
PropTypeDefaultDescription
items{ label, to?, children? }[]Children take { label, to?, action?, shortcut?, destructive? }.
labelstringMenu
Source · ads/app/components/AdsMenubar.vue
vue
<script setup lang="ts">
// An application menu bar — File, Edit, View — for a tool, not for site
// navigation (that is AdsNavMenu). It behaves like a desktop menu bar: Left
// and Right walk the bar, Down or Enter opens a menu, Escape closes it and
// hands focus back to its title. Inside a menu, shortcuts sit on the right and
// destructive items go last, below a rule. Hover reaches for the accent.
export type MenubarChild = { label: string; to?: string; action?: () => void; shortcut?: string; destructive?: boolean }
export type MenubarItem = { label: string; to?: string; children?: MenubarChild[] }
const props = withDefaults(defineProps<{ items: MenubarItem[]; label?: string }>(), { label: 'Menu' })

const uid = useId()
const bar = ref<HTMLElement>()
const open = ref<number | null>(null)
const focusIndex = ref(0)

function ordered(children: MenubarChild[] = []) {
  return [...children.filter((c) => !c.destructive), ...children.filter((c) => c.destructive)]
}

function firstRisky(children: MenubarChild[] = []) {
  const safe = children.filter((c) => !c.destructive).length
  return safe && safe < children.length ? safe : -1
}

function tops() {
  return Array.from(bar.value?.querySelectorAll<HTMLElement>('.ads-menubar__top') ?? [])
}

function subItems(i: number) {
  return Array.from(bar.value?.querySelectorAll<HTMLElement>(`#${CSS.escape(`${uid}-menu-${i}`)} [role="menuitem"]`) ?? [])
}

async function openMenu(i: number, focus: 'first' | 'last' | 'none' = 'first') {
  open.value = i
  focusIndex.value = i
  await nextTick()
  const items = subItems(i)
  if (focus === 'first') items[0]?.focus()
  else if (focus === 'last') items[items.length - 1]?.focus()
}

function closeMenu(refocus = true) {
  const i = open.value
  open.value = null
  if (refocus && i !== null) tops()[i]?.focus()
}

function moveTop(i: number) {
  const n = props.items.length
  const next = (i + n) % n
  focusIndex.value = next
  if (open.value !== null && props.items[next]?.children?.length) openMenu(next)
  else {
    open.value = null
    tops()[next]?.focus()
  }
}

function onTopKeydown(i: number, e: KeyboardEvent) {
  const hasMenu = !!props.items[i]?.children?.length
  switch (e.key) {
    case 'ArrowRight': e.preventDefault(); moveTop(i + 1); break
    case 'ArrowLeft': e.preventDefault(); moveTop(i - 1); break
    case 'Home': e.preventDefault(); moveTop(0); break
    case 'End': e.preventDefault(); moveTop(props.items.length - 1); break
    case 'ArrowDown':
    case 'Enter':
    case ' ':
      if (hasMenu) { e.preventDefault(); openMenu(i) }
      break
    case 'ArrowUp':
      if (hasMenu) { e.preventDefault(); openMenu(i, 'last') }
      break
    case 'Escape':
      if (open.value !== null) { e.preventDefault(); closeMenu() }
      break
  }
}

function onMenuKeydown(i: number, e: KeyboardEvent) {
  const items = subItems(i)
  const at = items.indexOf(document.activeElement as HTMLElement)
  const go = (n: number) => items[(n + items.length) % items.length]?.focus()
  switch (e.key) {
    case 'ArrowDown': e.preventDefault(); go(at + 1); break
    case 'ArrowUp': e.preventDefault(); go(at - 1); break
    case 'Home': e.preventDefault(); go(0); break
    case 'End': e.preventDefault(); go(items.length - 1); break
    case 'ArrowRight': e.preventDefault(); moveTop(i + 1); break
    case 'ArrowLeft': e.preventDefault(); moveTop(i - 1); break
    case 'Escape': e.preventDefault(); closeMenu(); break
    case 'Tab': open.value = null; break
  }
}

function pick(c: MenubarChild) {
  closeMenu()
  c.action?.()
}

function onFocusout(e: FocusEvent) {
  if (!bar.value?.contains(e.relatedTarget as Node)) open.value = null
}
</script>

<template>
  <div ref="bar" class="ads-menubar" role="menubar" :aria-label="label" @focusout="onFocusout">
    <div v-for="(it, i) in items" :key="it.label" class="ads-menubar__group">
      <NuxtLink
        v-if="it.to && !it.children?.length"
        :to="it.to"
        class="ads-menubar__top"
        role="menuitem"
        :tabindex="focusIndex === i ? 0 : -1"
        @keydown="onTopKeydown(i, $event)"
      >{{ it.label }}</NuxtLink>
      <template v-else>
        <button
          type="button"
          class="ads-menubar__top"
          role="menuitem"
          aria-haspopup="menu"
          :aria-expanded="open === i"
          :aria-controls="`${uid}-menu-${i}`"
          :tabindex="focusIndex === i ? 0 : -1"
          @click="open === i ? closeMenu(false) : openMenu(i, 'none')"
          @keydown="onTopKeydown(i, $event)"
        >
          {{ it.label }}
          <svg class="ads-menubar__chevron" viewBox="0 0 12 12" width="11" height="11" aria-hidden="true">
            <path d="M3 4.5l3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
          </svg>
        </button>
        <div
          v-show="open === i"
          :id="`${uid}-menu-${i}`"
          class="ads-menubar__menu"
          role="menu"
          :aria-label="it.label"
          @keydown="onMenuKeydown(i, $event)"
        >
          <template v-for="(c, k) in ordered(it.children)" :key="c.label">
            <hr v-if="k === firstRisky(it.children)" class="ads-menubar__rule" role="separator">
            <NuxtLink
              v-if="c.to"
              :to="c.to"
              class="ads-menubar__item"
              role="menuitem"
              tabindex="-1"
              @click="closeMenu(false)"
            >
              <span>{{ c.label }}</span>
              <kbd v-if="c.shortcut" class="ads-menubar__shortcut">{{ c.shortcut }}</kbd>
            </NuxtLink>
            <button v-else type="button" class="ads-menubar__item" role="menuitem" tabindex="-1" @click="pick(c)">
              <span>{{ c.label }}</span>
              <kbd v-if="c.shortcut" class="ads-menubar__shortcut">{{ c.shortcut }}</kbd>
            </button>
          </template>
        </div>
      </template>
    </div>
  </div>
</template>

<style scoped>
.ads-menubar {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-1);
  padding: 6px;
  border-radius: var(--pill);
  background: var(--raised);
  box-shadow: var(--soft-raised-sm);
}

.ads-menubar__group {
  position: relative;
}

.ads-menubar__top {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  height: 36px;
  padding: 0 var(--sp-3);
  border-radius: var(--pill);
  color: var(--text-soft);
  font-size: 0.9rem;
  white-space: nowrap;
  transition: color var(--dur-control) var(--ease), box-shadow var(--dur-control) var(--ease);
}

.ads-menubar__top:hover {
  color: var(--accent);
}

.ads-menubar__top:active,
.ads-menubar__top[aria-expanded='true'] {
  box-shadow: var(--soft-inset-sm);
}

.ads-menubar__chevron {
  color: var(--subdued);
  transition: transform var(--dur-control) var(--ease);
}

.ads-menubar__top[aria-expanded='true'] .ads-menubar__chevron {
  transform: rotate(180deg);
}

.ads-menubar__menu {
  position: absolute;
  top: calc(100% + 6px);
  left: 0;
  z-index: 20;
  display: grid;
  gap: 2px;
  min-width: 200px;
  padding: 6px;
  border-radius: var(--radius-sm);
  background: var(--elevated);
  box-shadow: var(--soft-raised-lg);
}

.ads-menubar__item {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  width: 100%;
  padding: var(--sp-2) 10px;
  border-radius: 8px;
  color: var(--text-soft);
  font-size: 0.86rem;
  text-align: left;
  transition: color var(--dur-control) var(--ease), background var(--dur-control) var(--ease);
}

.ads-menubar__item:hover,
.ads-menubar__item:focus-visible {
  background: var(--hover);
  color: var(--accent);
}

.ads-menubar__shortcut {
  margin-left: auto;
  font-family: var(--mono);
  font-size: 0.7rem;
  color: var(--subdued);
}

.ads-menubar__rule {
  height: 1px;
  margin: var(--sp-1) 0;
  border: 0;
  background: var(--line);
}
</style>

AdsNavMenu

Site navigation; sub-pages open from their own toggle.

vue
<AdsNavMenu :links="[{ label: 'Work', to: '/work', children: [{ label: 'CrawlStat', to: '/work/crawlstat' }] }]" />
AdsNavMenu props
PropTypeDefaultDescription
links{ label, to, children? }[]
labelstringPrimary
Source · ads/app/components/AdsNavMenu.vue
vue
<script setup lang="ts">
// Site navigation with optional sub-pages. Each parent stays a real link, and
// its sub-pages open from a separate small button, so the link works with one
// click and the list works from the keyboard (disclosure, not an ARIA menu —
// these are links, not commands). Escape closes and returns focus to the
// toggle; the current page is marked by aria-current, not by colour alone.
export type NavLink = { label: string; to: string; children?: { label: string; to: string }[] }
withDefaults(defineProps<{ links: NavLink[]; label?: string }>(), { label: 'Primary' })

const uid = useId()
const root = ref<HTMLElement>()
const open = ref<number | null>(null)

function toggle(i: number) {
  open.value = open.value === i ? null : i
}

function onKeydown(e: KeyboardEvent) {
  if (e.key !== 'Escape' || open.value === null) return
  const i = open.value
  open.value = null
  root.value?.querySelector<HTMLElement>(`#${CSS.escape(`${uid}-toggle-${i}`)}`)?.focus()
}

function onFocusout(e: FocusEvent) {
  if (!root.value?.contains(e.relatedTarget as Node)) open.value = null
}

function onDocClick(e: MouseEvent) {
  if (!root.value?.contains(e.target as Node)) open.value = null
}

onMounted(() => document.addEventListener('click', onDocClick))
onBeforeUnmount(() => document.removeEventListener('click', onDocClick))
</script>

<template>
  <nav ref="root" class="ads-nav-menu" :aria-label="label" @keydown="onKeydown" @focusout="onFocusout">
    <ul class="ads-nav-menu__list">
      <li v-for="(l, i) in links" :key="l.label" class="ads-nav-menu__group">
        <NuxtLink :to="l.to" class="ads-nav-menu__link">{{ l.label }}</NuxtLink>
        <template v-if="l.children?.length">
          <button
            :id="`${uid}-toggle-${i}`"
            type="button"
            class="ads-nav-menu__toggle"
            :aria-expanded="open === i"
            :aria-controls="`${uid}-sub-${i}`"
            :aria-label="`More in ${l.label}`"
            @click="toggle(i)"
          >
            <svg class="ads-nav-menu__chevron" viewBox="0 0 12 12" width="11" height="11" aria-hidden="true">
              <path d="M3 4.5l3 3 3-3" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" />
            </svg>
          </button>
          <ul v-show="open === i" :id="`${uid}-sub-${i}`" class="ads-nav-menu__drop">
            <li v-for="c in l.children" :key="c.label">
              <NuxtLink :to="c.to" class="ads-nav-menu__sub" @click="open = null">{{ c.label }}</NuxtLink>
            </li>
          </ul>
        </template>
      </li>
    </ul>
    <slot />
  </nav>
</template>

<style scoped>
.ads-nav-menu {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
}

.ads-nav-menu__list {
  display: flex;
  align-items: center;
  gap: 2px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.ads-nav-menu__group {
  position: relative;
  display: flex;
  align-items: center;
}

.ads-nav-menu__link {
  display: inline-flex;
  align-items: center;
  height: 38px;
  padding: 0 var(--sp-3);
  border-radius: var(--pill);
  color: var(--text-soft);
  font-size: 0.92rem;
  white-space: nowrap;
  transition: color var(--dur-control) var(--ease), box-shadow var(--dur-control) var(--ease);
}

.ads-nav-menu__link:hover,
.ads-nav-menu__toggle:hover {
  color: var(--accent);
}

.ads-nav-menu__link:active,
.ads-nav-menu__toggle:active {
  box-shadow: var(--soft-inset-sm);
}

/* The current page is pressed in and set in full text — shape plus tone. */
.ads-nav-menu__link[aria-current='page'] {
  color: var(--text);
  box-shadow: var(--soft-inset-sm);
}

.ads-nav-menu__toggle {
  display: inline-grid;
  place-items: center;
  width: 28px;
  height: 28px;
  margin-left: -6px;
  border-radius: var(--pill);
  color: var(--subdued);
  transition: color var(--dur-control) var(--ease), box-shadow var(--dur-control) var(--ease);
}

.ads-nav-menu__chevron {
  transition: transform var(--dur-control) var(--ease);
}

.ads-nav-menu__toggle[aria-expanded='true'] .ads-nav-menu__chevron {
  transform: rotate(180deg);
}

.ads-nav-menu__drop {
  position: absolute;
  top: calc(100% + 6px);
  left: 0;
  z-index: 20;
  display: grid;
  gap: 2px;
  min-width: 190px;
  margin: 0;
  padding: 6px;
  list-style: none;
  border-radius: var(--radius-sm);
  background: var(--elevated);
  box-shadow: var(--soft-raised-lg);
}

.ads-nav-menu__sub {
  display: block;
  padding: 9px var(--sp-3);
  border-radius: 9px;
  color: var(--text-soft);
  font-size: 0.88rem;
  transition: color var(--dur-control) var(--ease), background var(--dur-control) var(--ease);
}

.ads-nav-menu__sub:hover {
  background: var(--hover);
  color: var(--accent);
}

.ads-nav-menu__sub[aria-current='page'] {
  color: var(--text);
}
</style>

Data & motion

AdsCalendar

A month grid with ISO dates and full keyboard support.

Calendar, 2026-10

MoTuWeThFrSaSu
—
vue
<AdsCalendar v-model="day" v-model:month="month" />
<!-- day: '2026-10-27', month: '2026-10' -->
AdsCalendar props
PropTypeDefaultDescription
v-modelstringISO day, YYYY-MM-DD.
v-model:monthstringMonth on view, YYYY-MM. Defaults to the chosen day or this month.
labelstringCalendar
Source · ads/app/components/AdsCalendar.vue
vue
<script setup lang="ts">
// A month grid for picking one day. Dates are ISO — the month reads
// "2026-10", the value is "2026-10-27" — and every figure is in the data face
// so the columns line up. It is a real grid: arrows move a day or a week,
// PageUp/PageDown move a month, Home/End go to the week's ends, and each day is
// a button that reads out its full date. The chosen day takes the accent; today
// is pressed in, so the two never depend on colour to be told apart.
withDefaults(defineProps<{ label?: string }>(), { label: 'Calendar' })
const value = defineModel<string>({ default: '' })

function pad(n: number) {
  return String(n).padStart(2, '0')
}
function iso(d: Date) {
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
function parse(s: string) {
  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s)
  return m ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])) : null
}

const todayIso = iso(new Date())
// The month on view, as YYYY-MM. Left unbound, it opens on the chosen day's
// month, or this month.
const month = defineModel<string>('month', { default: '' })
if (!month.value) month.value = (value.value || todayIso).slice(0, 7)

const uid = useId()
const grid = ref<HTMLElement>()
const focusIso = ref(value.value || (month.value === todayIso.slice(0, 7) ? todayIso : `${month.value}-01`))

const WEEKDAYS = [
  ['Mo', 'Monday'], ['Tu', 'Tuesday'], ['We', 'Wednesday'], ['Th', 'Thursday'],
  ['Fr', 'Friday'], ['Sa', 'Saturday'], ['Su', 'Sunday'],
] as const

const weeks = computed(() => {
  const [y, m] = month.value.split('-').map(Number) as [number, number]
  const first = new Date(y, m - 1, 1)
  const lead = (first.getDay() + 6) % 7 // Monday-first
  const count = new Date(y, m, 0).getDate()
  const cells: (string | null)[] = Array(lead).fill(null)
  for (let d = 1; d <= count; d++) cells.push(`${month.value}-${pad(d)}`)
  while (cells.length % 7) cells.push(null)
  const out: (string | null)[][] = []
  for (let i = 0; i < cells.length; i += 7) out.push(cells.slice(i, i + 7))
  return out
})

// Keep the roving focus inside the month on view.
watch(month, (m) => {
  if (focusIso.value.slice(0, 7) !== m) {
    const day = Math.min(Number(focusIso.value.slice(8)) || 1, new Date(Number(m.slice(0, 4)), Number(m.slice(5)), 0).getDate())
    focusIso.value = `${m}-${pad(day)}`
  }
})

watch(value, (v) => {
  if (v && parse(v)) {
    focusIso.value = v
    month.value = v.slice(0, 7)
  }
})

function shiftMonth(n: number) {
  const [y, m] = month.value.split('-').map(Number) as [number, number]
  const d = new Date(y, m - 1 + n, 1)
  month.value = iso(d).slice(0, 7)
}

async function moveTo(d: Date) {
  focusIso.value = iso(d)
  month.value = focusIso.value.slice(0, 7)
  await nextTick()
  grid.value?.querySelector<HTMLElement>(`[data-iso="${focusIso.value}"]`)?.focus()
}

function onKeydown(e: KeyboardEvent) {
  const d = parse(focusIso.value)
  if (!d) return
  const dow = (d.getDay() + 6) % 7
  const step: Record<string, () => void> = {
    ArrowLeft: () => d.setDate(d.getDate() - 1),
    ArrowRight: () => d.setDate(d.getDate() + 1),
    ArrowUp: () => d.setDate(d.getDate() - 7),
    ArrowDown: () => d.setDate(d.getDate() + 7),
    Home: () => d.setDate(d.getDate() - dow),
    End: () => d.setDate(d.getDate() + (6 - dow)),
    PageUp: () => d.setMonth(d.getMonth() - (e.shiftKey ? 12 : 1)),
    PageDown: () => d.setMonth(d.getMonth() + (e.shiftKey ? 12 : 1)),
  }
  const fn = step[e.key]
  if (!fn) return
  e.preventDefault()
  fn()
  moveTo(d)
}

function pick(day: string) {
  value.value = day
  focusIso.value = day
}
</script>

<template>
  <div class="ads-calendar" role="group" :aria-labelledby="`${uid}-month`">
    <div class="ads-calendar__head">
      <button type="button" class="ads-calendar__nav" aria-label="Previous month" @click="shiftMonth(-1)">
        <Icon name="arrow" :size="14" style="transform: rotate(180deg)" />
      </button>
      <p :id="`${uid}-month`" class="ads-calendar__month" aria-live="polite">
        <span class="sr-only">{{ label }}, </span>{{ month }}
      </p>
      <button type="button" class="ads-calendar__nav" aria-label="Next month" @click="shiftMonth(1)">
        <Icon name="arrow" :size="14" />
      </button>
    </div>
    <table ref="grid" class="ads-calendar__grid" role="grid" :aria-labelledby="`${uid}-month`" @keydown="onKeydown">
      <thead>
        <tr>
          <th v-for="[short, full] in WEEKDAYS" :key="short" scope="col" class="ads-calendar__weekday">
            <abbr :title="full">{{ short }}</abbr>
          </th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="(week, w) in weeks" :key="w">
          <td v-for="(day, i) in week" :key="i" role="gridcell" :aria-selected="day ? day === value : undefined">
            <button
              v-if="day"
              type="button"
              class="ads-calendar__day"
              :class="{ 'is-on': day === value, 'is-today': day === todayIso }"
              :data-iso="day"
              :tabindex="day === focusIso ? 0 : -1"
              :aria-label="day"
              :aria-current="day === todayIso ? 'date' : undefined"
              :aria-pressed="day === value"
              @click="pick(day)"
              @focus="focusIso = day"
            >{{ Number(day.slice(8)) }}</button>
          </td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<style scoped>
.ads-calendar {
  width: fit-content;
  padding: var(--sp-4);
  border-radius: var(--radius-sm);
  background: var(--surface);
  box-shadow: var(--soft-inset-sm);
}

.ads-calendar__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-2);
  margin-bottom: var(--sp-3);
}

.ads-calendar__month {
  margin: 0;
  font-family: var(--mono);
  font-size: 0.9rem;
  font-variant-numeric: tabular-nums;
  color: var(--text);
}

.ads-calendar__nav {
  display: inline-grid;
  place-items: center;
  width: 30px;
  height: 30px;
  border-radius: 9px;
  background: var(--raised);
  box-shadow: var(--soft-raised-sm);
  color: var(--text-soft);
  transition: color var(--dur-control) var(--ease), box-shadow var(--dur-control) var(--ease);
}

.ads-calendar__nav:hover {
  color: var(--accent);
}

.ads-calendar__nav:active {
  box-shadow: var(--soft-inset-sm);
}

.ads-calendar__grid {
  border-collapse: separate;
  border-spacing: 2px;
}

.ads-calendar__weekday {
  height: 28px;
  font-family: var(--font-label);
  font-size: 0.62rem;
  font-weight: 500;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--subdued);
}

.ads-calendar__weekday abbr {
  text-decoration: none;
}

.ads-calendar__day {
  display: grid;
  place-items: center;
  width: 34px;
  height: 32px;
  border-radius: 9px;
  color: var(--text-soft);
  font-family: var(--mono);
  font-size: 0.82rem;
  font-variant-numeric: tabular-nums;
  transition: color var(--dur-control) var(--ease), box-shadow var(--dur-control) var(--ease);
}

.ads-calendar__day:hover {
  color: var(--accent);
}

.ads-calendar__day:active,
.ads-calendar__day.is-today {
  box-shadow: var(--soft-inset-sm);
}

.ads-calendar__day.is-today {
  color: var(--text);
}

.ads-calendar__day.is-on {
  background: var(--accent);
  color: var(--accent-ink);
  box-shadow: none;
}
</style>

AdsCarousel

A horizontal rail; the edge with more to see fades.

vue
<AdsCarousel label="Case studies">
  <AdsAspectRatio ratio="4 / 3">Slide 1</AdsAspectRatio>
  <AdsAspectRatio ratio="4 / 3">Slide 2</AdsAspectRatio>
</AdsCarousel>
AdsCarousel props
PropTypeDefaultDescription
labelstringRequired. Names the region.
Source · ads/app/components/AdsCarousel.vue
vue
<script setup lang="ts">
// A horizontal rail of cards. Scrollbars are hidden site-wide, so the rail has
// to say for itself that there is more: the edge with more to see fades out,
// and the arrow on a finished side goes quiet. The rail is focusable, so arrow
// keys scroll it too, and it snaps to whole cards. Reduced motion jumps
// instead of gliding.
withDefaults(defineProps<{ label: string }>(), {})
const rail = ref<HTMLElement>()
const atStart = ref(true)
const atEnd = ref(true)
let ro: ResizeObserver | undefined

function measure() {
  const el = rail.value
  if (!el) return
  atStart.value = el.scrollLeft <= 1
  atEnd.value = el.scrollLeft + el.clientWidth >= el.scrollWidth - 1
}

function scroll(dir: 1 | -1) {
  const el = rail.value
  if (!el) return
  const still = window.matchMedia('(prefers-reduced-motion: reduce)').matches
  el.scrollBy({ left: dir * el.clientWidth * 0.8, behavior: still ? 'auto' : 'smooth' })
}

onMounted(() => {
  measure()
  ro = new ResizeObserver(measure)
  if (rail.value) ro.observe(rail.value)
})
onBeforeUnmount(() => ro?.disconnect())
</script>

<template>
  <section class="ads-carousel" :aria-label="label" aria-roledescription="carousel">
    <button
      type="button"
      class="ads-carousel__button is-prev"
      :aria-label="`Scroll ${label} back`"
      :disabled="atStart"
      @click="scroll(-1)"
    >
      <Icon name="arrow" :size="15" />
    </button>
    <div
      ref="rail"
      class="ads-carousel__rail"
      :class="{ 'is-more-start': !atStart, 'is-more-end': !atEnd }"
      tabindex="0"
      role="group"
      :aria-label="`${label} items`"
      @scroll.passive="measure"
    >
      <slot />
    </div>
    <button
      type="button"
      class="ads-carousel__button"
      :aria-label="`Scroll ${label} forward`"
      :disabled="atEnd"
      @click="scroll(1)"
    >
      <Icon name="arrow" :size="15" />
    </button>
  </section>
</template>

<style scoped>
.ads-carousel {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  gap: var(--sp-2);
}

.ads-carousel__rail {
  --ads-carousel-fade-start: 0px;
  --ads-carousel-fade-end: 0px;
  display: flex;
  gap: var(--sp-3);
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  padding: var(--sp-1) 2px var(--sp-3);
  border-radius: var(--radius-sm);
  /* The fade is the scrollbar: the edge that still has cards behind it
     dissolves, so the rail reads as continuing. */
  mask-image: linear-gradient(
    to right,
    transparent 0,
    #000 var(--ads-carousel-fade-start),
    #000 calc(100% - var(--ads-carousel-fade-end)),
    transparent 100%
  );
}

.ads-carousel__rail.is-more-start {
  --ads-carousel-fade-start: var(--sp-7);
}

.ads-carousel__rail.is-more-end {
  --ads-carousel-fade-end: var(--sp-7);
}

.ads-carousel__rail > :deep(*) {
  flex: none;
  scroll-snap-align: start;
}

.ads-carousel__button {
  display: grid;
  place-items: center;
  width: 36px;
  height: 36px;
  flex: none;
  border-radius: 50%;
  background: var(--raised);
  box-shadow: var(--soft-raised-sm);
  color: var(--text-soft);
  transition:
    color var(--dur-control) var(--ease),
    box-shadow var(--dur-control) var(--ease),
    opacity var(--dur-control) var(--ease);
}

.ads-carousel__button.is-prev :deep(svg) {
  transform: rotate(180deg);
}

.ads-carousel__button:hover:not(:disabled) {
  color: var(--accent);
}

.ads-carousel__button:active:not(:disabled) {
  box-shadow: var(--soft-inset-sm);
}

.ads-carousel__button:disabled {
  opacity: 0.35;
  box-shadow: none;
  cursor: default;
}
</style>

AdsTrace

A vertical trail of steps.

  1. Discovery, Donewk 1
  2. Design system, In progresswk 2
  3. Interface build, To dowk 3
  4. Handover, To dowk 4
vue
<AdsTrace
  label="Project timeline"
  :steps="[{ title: 'Discovery', state: 'done' }, { title: 'Build', state: 'now' }, { title: 'Handover', state: 'todo' }]"
/>
AdsTrace props
PropTypeDefaultDescription
steps{ title, meta?, state?: 'done' | 'now' | 'todo' }[]
labelstring
Source · ads/app/components/AdsTrace.vue
vue
<script setup lang="ts">
// A vertical trail of steps — a deploy, an order, an approval — threaded by
// one hairline. It speaks the same language as the stepper: done is filled and
// ticked, the current step is ringed and marked aria-current, what is still to
// come is pressed in. Each state is also written out for screen readers, so
// nothing rides on the fill alone. Times are figures, so they are in the data
// face.
export type TraceStep = { title: string; meta?: string; state?: 'done' | 'now' | 'todo' }
const props = defineProps<{ steps: TraceStep[]; label?: string }>()

const STATE_TEXT = { done: 'Done', now: 'In progress', todo: 'To do' } as const
function stateOf(s: TraceStep, i: number) {
  return s.state ?? (i === 0 ? 'done' : 'todo')
}
</script>

<template>
  <ol class="ads-trace" :aria-label="props.label">
    <li
      v-for="(s, i) in steps"
      :key="s.title"
      class="ads-trace__step"
      :class="`is-${stateOf(s, i)}`"
      :aria-current="stateOf(s, i) === 'now' ? 'step' : undefined"
    >
      <span class="ads-trace__dot" aria-hidden="true">
        <Icon v-if="stateOf(s, i) === 'done'" name="check" :size="10" />
      </span>
      <div class="ads-trace__body">
        <span class="ads-trace__title">
          {{ s.title }}<span class="sr-only">, {{ STATE_TEXT[stateOf(s, i)] }}</span>
        </span>
        <span v-if="s.meta" class="ads-trace__meta num">{{ s.meta }}</span>
      </div>
      <span v-if="i < steps.length - 1" class="ads-trace__line" aria-hidden="true" />
    </li>
  </ol>
</template>

<style scoped>
.ads-trace {
  display: grid;
  margin: 0;
  padding: 0;
  list-style: none;
}

.ads-trace__step {
  position: relative;
  display: grid;
  grid-template-columns: 18px 1fr;
  gap: var(--sp-3);
  padding-bottom: var(--sp-5);
}

.ads-trace__step:last-child {
  padding-bottom: 0;
}

.ads-trace__dot {
  position: relative;
  z-index: 1;
  display: grid;
  place-items: center;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: var(--surface);
  box-shadow: var(--soft-inset-sm);
}

.ads-trace__step.is-done .ads-trace__dot {
  background: var(--accent);
  color: var(--accent-ink);
  box-shadow: var(--soft-raised-sm);
}

.ads-trace__step.is-now .ads-trace__dot {
  background: var(--raised);
  box-shadow: var(--soft-raised-sm), 0 0 0 2px var(--accent);
}

.ads-trace__line {
  position: absolute;
  top: 22px;
  bottom: 4px;
  left: 8.5px;
  width: 1px;
  background: var(--line);
}

.ads-trace__body {
  display: grid;
  gap: 2px;
}

.ads-trace__title {
  font-size: 0.92rem;
  color: var(--text-soft);
}

.ads-trace__step.is-now .ads-trace__title {
  color: var(--text);
  font-weight: 540;
}

.ads-trace__step.is-todo .ads-trace__title {
  color: var(--subdued);
}

.ads-trace__meta {
  font-size: 0.72rem;
  color: var(--subdued);
}
</style>

AdsQuestionnaire

One question at a time, with a progress readout.

Question 1 of 2

What do you need?

vue
<AdsQuestionnaire
  :questions="[{ id: 'q1', text: 'What do you need?', options: ['A site', 'A dashboard'] }]"
  submit-label="Send answers"
  @done="save"
/>
AdsQuestionnaire props
PropTypeDefaultDescription
questions{ id, text, options, multi? }[]
submit-labelstringSend answers
Source · ads/app/components/AdsQuestionnaire.vue
vue
<script setup lang="ts">
// One question at a time, with how far along you are written as figures as
// well as drawn. A chosen answer presses in and takes a tick — the accent is
// kept for the one button that moves you on, which stays disabled until the
// question is answered. Moving between questions puts focus on the new
// question, so a screen reader hears it straight away.
export type Question = { id: string; text: string; options: string[]; multi?: boolean }

const props = withDefaults(defineProps<{ questions: Question[]; submitLabel?: string }>(), {
  submitLabel: 'Send answers',
})
const emit = defineEmits<{ (e: 'done', answers: Record<string, string[]>): void }>()
const step = ref(0)
const answers = reactive<Record<string, string[]>>({})
const heading = ref<HTMLElement>()

const current = computed(() => props.questions[step.value])
const total = computed(() => props.questions.length)
const progress = computed(() => (total.value ? ((step.value + 1) / total.value) * 100 : 0))
const answered = computed(() => !!current.value && (answers[current.value.id]?.length ?? 0) > 0)
const last = computed(() => step.value >= total.value - 1)

function chosen(opt: string) {
  return !!current.value && (answers[current.value.id] ?? []).includes(opt)
}

function toggle(opt: string) {
  const q = current.value
  if (!q) return
  const cur = answers[q.id] ?? []
  answers[q.id] = q.multi ? (cur.includes(opt) ? cur.filter((o) => o !== opt) : [...cur, opt]) : [opt]
}

async function go(n: number) {
  step.value = n
  await nextTick()
  heading.value?.focus()
}

function next() {
  if (!answered.value) return
  if (!last.value) go(step.value + 1)
  else emit('done', { ...answers })
}
</script>

<template>
  <div class="ads-questionnaire">
    <div class="ads-questionnaire__progress" aria-hidden="true">
      <span class="ads-questionnaire__bar" :style="{ width: `${progress}%` }" />
    </div>
    <p class="ads-questionnaire__count num">
      <span class="sr-only">Question </span>{{ step + 1 }}<span aria-hidden="true"> / </span><span class="sr-only"> of </span>{{ total }}
    </p>
    <h3 ref="heading" class="ads-questionnaire__question h3" tabindex="-1">{{ current?.text }}</h3>
    <div
      class="ads-questionnaire__options"
      role="group"
      :aria-label="current?.multi ? 'Choose any that apply' : 'Choose one'"
    >
      <button
        v-for="o in current?.options ?? []"
        :key="o"
        type="button"
        class="ads-questionnaire__option"
        :class="{ 'is-on': chosen(o) }"
        :aria-pressed="chosen(o)"
        @click="toggle(o)"
      >
        <Icon v-if="chosen(o)" name="check" :size="13" />
        {{ o }}
      </button>
    </div>
    <div class="ads-questionnaire__foot">
      <button v-if="step > 0" type="button" class="btn btn-quiet" @click="go(step - 1)">Previous question</button>
      <button type="button" class="btn btn-accent" :disabled="!answered" @click="next">
        {{ last ? submitLabel : 'Next question' }}
      </button>
    </div>
  </div>
</template>

<style scoped>
.ads-questionnaire {
  display: grid;
  gap: var(--sp-4);
  max-width: 560px;
}

.ads-questionnaire__progress {
  height: 6px;
  overflow: hidden;
  border-radius: var(--pill);
  background: var(--surface);
  box-shadow: var(--soft-inset-sm);
}

.ads-questionnaire__bar {
  display: block;
  height: 100%;
  border-radius: var(--pill);
  background: var(--text-soft);
  transition: width var(--dur-move) var(--ease);
}

.ads-questionnaire__count {
  margin: 0;
  color: var(--subdued);
}

.ads-questionnaire__question {
  margin: 0;
}

.ads-questionnaire__options {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2);
}

.ads-questionnaire__option {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 10px var(--sp-4);
  border-radius: var(--pill);
  background: var(--raised);
  box-shadow: var(--soft-raised-sm);
  color: var(--text-soft);
  transition:
    color var(--dur-control) var(--ease),
    background var(--dur-control) var(--ease),
    box-shadow var(--dur-control) var(--ease);
}

.ads-questionnaire__option:hover {
  color: var(--accent);
}

.ads-questionnaire__option:active,
.ads-questionnaire__option.is-on {
  background: var(--surface);
  box-shadow: var(--soft-inset-sm);
}

.ads-questionnaire__option.is-on {
  color: var(--text);
}

.ads-questionnaire__foot {
  display: flex;
  gap: var(--sp-2);
  margin-top: var(--sp-1);
}
</style>

AdsResizable

A panel you can drag or key wider.

Drag the right edge, or focus it and use the arrow keys.

280 px
vue
<AdsResizable v-model:width="width" :min="200" :max="560" label="Resize sidebar">
  A panel you can drag wider.
</AdsResizable>
AdsResizable props
PropTypeDefaultDescription
v-model:widthnumber280Width in pixels.
minnumber160
maxnumber720
labelstringResize panel
Source · ads/app/components/AdsResizable.vue
vue
<script setup lang="ts">
// A panel whose width is yours to set. The edge is a real separator: you can
// drag it, or focus it and use the arrow keys (Shift for bigger steps), and it
// reports its width as a value. The handle is a visible hairline at rest and
// brightens to the accent when touched — a control has to look like one
// before it is hovered.
const props = withDefaults(defineProps<{ min?: number; max?: number; label?: string }>(), {
  min: 160,
  max: 720,
  label: 'Resize panel',
})
const width = defineModel<number>('width', { default: 280 })
const root = ref<HTMLElement>()
const dragging = ref(false)

function clamp(n: number) {
  return Math.round(Math.min(props.max, Math.max(props.min, n)))
}

function onPointerdown(e: PointerEvent) {
  dragging.value = true
  ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
  e.preventDefault()
}

function onPointermove(e: PointerEvent) {
  if (!dragging.value || !root.value) return
  width.value = clamp(e.clientX - root.value.getBoundingClientRect().left)
}

function onPointerup(e: PointerEvent) {
  dragging.value = false
  ;(e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId)
}

function onKeydown(e: KeyboardEvent) {
  const step = e.shiftKey ? 64 : 16
  const map: Record<string, number> = {
    ArrowLeft: width.value - step,
    ArrowRight: width.value + step,
    Home: props.min,
    End: props.max,
  }
  if (!(e.key in map)) return
  e.preventDefault()
  width.value = clamp(map[e.key]!)
}
</script>

<template>
  <div ref="root" class="ads-resizable" :class="{ 'is-dragging': dragging }" :style="{ width: `${width}px` }">
    <div class="ads-resizable__body"><slot /></div>
    <div
      class="ads-resizable__handle"
      role="separator"
      tabindex="0"
      aria-orientation="vertical"
      :aria-label="label"
      :aria-valuenow="width"
      :aria-valuemin="min"
      :aria-valuemax="max"
      :aria-valuetext="`${width} pixels`"
      @pointerdown="onPointerdown"
      @pointermove="onPointermove"
      @pointerup="onPointerup"
      @pointercancel="onPointerup"
      @keydown="onKeydown"
    />
  </div>
</template>

<style scoped>
.ads-resizable {
  position: relative;
  max-width: 100%;
}

.ads-resizable__body {
  height: 100%;
  padding: var(--sp-3) var(--sp-4);
  overflow: auto;
  border-radius: var(--radius-sm);
  background: var(--surface);
  box-shadow: var(--soft-inset-sm);
}

/* A wide, invisible hit area around a narrow visible bar. */
.ads-resizable__handle {
  position: absolute;
  top: var(--sp-2);
  right: -8px;
  bottom: var(--sp-2);
  width: 16px;
  border-radius: var(--pill);
  cursor: ew-resize;
  touch-action: none;
}

.ads-resizable__handle::after {
  content: '';
  position: absolute;
  top: 0;
  bottom: 0;
  left: 50%;
  width: 3px;
  translate: -50% 0;
  border-radius: var(--pill);
  background: var(--line-strong);
  transition: background var(--dur-control) var(--ease);
}

.ads-resizable__handle:hover::after,
.ads-resizable__handle:focus-visible::after,
.ads-resizable.is-dragging .ads-resizable__handle::after {
  background: var(--accent);
}
</style>

AdsScrollArea

A labelled scrolling region whose edges fade while there is more.

Line 1 — the chrome is grey so the data can be coloured.

Line 2 — the chrome is grey so the data can be coloured.

Line 3 — the chrome is grey so the data can be coloured.

Line 4 — the chrome is grey so the data can be coloured.

Line 5 — the chrome is grey so the data can be coloured.

Line 6 — the chrome is grey so the data can be coloured.

Line 7 — the chrome is grey so the data can be coloured.

Line 8 — the chrome is grey so the data can be coloured.

Line 9 — the chrome is grey so the data can be coloured.

Line 10 — the chrome is grey so the data can be coloured.

Line 11 — the chrome is grey so the data can be coloured.

Line 12 — the chrome is grey so the data can be coloured.

vue
<AdsScrollArea label="Changelog" height="180px">…long content…</AdsScrollArea>
AdsScrollArea props
PropTypeDefaultDescription
labelstringRequired. Names the focusable region.
heightstring
axis'y' | 'x' | 'both'y
Source · ads/app/components/AdsScrollArea.vue
vue
<script setup lang="ts">
// A box that scrolls. Scrollbars are hidden site-wide, so the area carries its
// own cue: whichever edge still has content behind it fades out, and the fade
// goes away once you reach that end. It is a labelled, focusable region, so
// the keyboard can scroll it as well.
const props = withDefaults(defineProps<{ label: string; height?: string; axis?: 'y' | 'x' | 'both' }>(), {
  height: '',
  axis: 'y',
})
const el = ref<HTMLElement>()
const more = reactive({ top: false, bottom: false, left: false, right: false })
let ro: ResizeObserver | undefined

function measure() {
  const s = el.value
  if (!s) return
  const y = props.axis !== 'x'
  const x = props.axis !== 'y'
  more.top = y && s.scrollTop > 1
  more.bottom = y && s.scrollTop + s.clientHeight < s.scrollHeight - 1
  more.left = x && s.scrollLeft > 1
  more.right = x && s.scrollLeft + s.clientWidth < s.scrollWidth - 1
}

onMounted(() => {
  measure()
  ro = new ResizeObserver(measure)
  if (el.value) {
    ro.observe(el.value)
    for (const c of Array.from(el.value.children)) ro.observe(c)
  }
})
onBeforeUnmount(() => ro?.disconnect())
</script>

<template>
  <div
    ref="el"
    class="ads-scroll-area"
    :class="[
      `is-${axis}`,
      { 'is-more-top': more.top, 'is-more-bottom': more.bottom, 'is-more-left': more.left, 'is-more-right': more.right },
    ]"
    :style="height ? { height } : undefined"
    role="region"
    :aria-label="label"
    tabindex="0"
    @scroll.passive="measure"
  >
    <slot />
  </div>
</template>

<style scoped>
.ads-scroll-area {
  --ads-scroll-area-fade-top: 0px;
  --ads-scroll-area-fade-bottom: 0px;
  --ads-scroll-area-fade-left: 0px;
  --ads-scroll-area-fade-right: 0px;
  overflow: auto;
  border-radius: var(--radius-sm);
  mask-image:
    linear-gradient(
      to bottom,
      transparent 0,
      #000 var(--ads-scroll-area-fade-top),
      #000 calc(100% - var(--ads-scroll-area-fade-bottom)),
      transparent 100%
    ),
    linear-gradient(
      to right,
      transparent 0,
      #000 var(--ads-scroll-area-fade-left),
      #000 calc(100% - var(--ads-scroll-area-fade-right)),
      transparent 100%
    );
  mask-composite: intersect;
}

.ads-scroll-area.is-y {
  overflow-x: hidden;
}

.ads-scroll-area.is-x {
  overflow-y: hidden;
}

.ads-scroll-area.is-more-top {
  --ads-scroll-area-fade-top: var(--sp-6);
}

.ads-scroll-area.is-more-bottom {
  --ads-scroll-area-fade-bottom: var(--sp-6);
}

.ads-scroll-area.is-more-left {
  --ads-scroll-area-fade-left: var(--sp-6);
}

.ads-scroll-area.is-more-right {
  --ads-scroll-area-fade-right: var(--sp-6);
}
</style>

AdsSpinner

A bare spinner with an accessible label.

vue
<AdsSpinner :size="22" label="Loading crawl" />
AdsSpinner props
PropTypeDefaultDescription
sizenumber18
labelstringLoading
Source · ads/app/components/AdsSpinner.vue
vue
<script setup lang="ts">
// A bare spinner, for an inline wait of unknown shape — next to the thing that
// is loading, never over the page. Where the shape is known, use a skeleton.
// The global rule stops it turning for readers who asked for less motion.
withDefaults(defineProps<{ size?: number; label?: string }>(), { size: 18, label: 'Loading' })
</script>

<template>
  <span class="ads-spinner" :style="{ width: `${size}px`, height: `${size}px` }" role="status" :aria-label="label" />
</template>

Messaging

AdsMessage

A single message in a thread.

Studio
The crawl finished — 1,284 URLs.
Great, what did it find?
vue
<AdsMessage name="Studio" time="09:41">The crawl finished.</AdsMessage>
Source · ads/app/components/AdsMessage.vue
vue
<script setup lang="ts">
// One message in a thread. The name sits in the meta line, so the avatar is
// decoration and stays out of the reading order. Your own messages drop the
// avatar and lean right; the time is a figure, so it is set in the data face.
withDefaults(defineProps<{ name?: string; mine?: boolean; time?: string }>(), { name: '', mine: false, time: '' })
</script>

<template>
  <div class="ads-message" :class="{ 'is-mine': mine }">
    <AdsAvatar v-if="name && !mine" :name="name" :size="32" decorative />
    <div class="ads-message__col">
      <div v-if="name || time" class="ads-message__meta">
        <span v-if="name" class="ads-message__name">{{ name }}</span>
        <time v-if="time" class="ads-message__time num">{{ time }}</time>
      </div>
      <AdsBubble :mine="mine"><slot /></AdsBubble>
    </div>
  </div>
</template>

<style scoped>
.ads-message {
  display: flex;
  align-items: flex-start;
  gap: var(--sp-2);
}

.ads-message.is-mine {
  justify-content: flex-end;
}

.ads-message__col {
  display: grid;
  gap: var(--sp-1);
  min-width: 0;
  max-width: 80%;
}

.ads-message.is-mine .ads-message__col {
  justify-items: end;
}

.ads-message__meta {
  display: flex;
  align-items: baseline;
  gap: var(--sp-2);
}

.ads-message__name {
  font-size: 0.78rem;
  color: var(--text-soft);
}

.ads-message__time {
  font-size: 0.68rem;
  color: var(--subdued);
}
</style>

AdsMessageScroller

A live log that follows new messages while you are at the bottom.

Studio
The crawl finished.
Great.
Studio
12 issues, 3 worth fixing now.
vue
<AdsMessageScroller label="Thread with Studio">
  <AdsMessage name="Studio">…</AdsMessage>
  <AdsMessage mine>…</AdsMessage>
</AdsMessageScroller>
AdsMessageScroller props
PropTypeDefaultDescription
labelstringMessages
stickbooleantrueFollow new messages while the reader is at the bottom.
Source · ads/app/components/AdsMessageScroller.vue
vue
<script setup lang="ts">
// The column a thread lives in. It is a live log: new messages are announced
// politely, and the view follows them only if the reader was already at the
// bottom — scrolling up to re-read is never yanked away. Focusable, so the
// keyboard can scroll it with no visible scrollbar.
const props = withDefaults(defineProps<{ label?: string; stick?: boolean }>(), {
  label: 'Messages',
  stick: true,
})
const el = ref<HTMLElement>()
let pinned = true
let observer: MutationObserver | undefined

function nearBottom() {
  const s = el.value
  return !!s && s.scrollHeight - s.scrollTop - s.clientHeight < 48
}

function toBottom() {
  el.value?.scrollTo({ top: el.value.scrollHeight })
}

onMounted(() => {
  if (!props.stick || !el.value) return
  toBottom()
  observer = new MutationObserver(() => {
    if (pinned) toBottom()
  })
  observer.observe(el.value, { childList: true, subtree: true, characterData: true })
})

onBeforeUnmount(() => observer?.disconnect())
</script>

<template>
  <div
    ref="el"
    class="ads-message-scroller"
    role="log"
    aria-live="polite"
    :aria-label="label"
    tabindex="0"
    @scroll.passive="pinned = nearBottom()"
  >
    <slot />
  </div>
</template>

<style scoped>
.ads-message-scroller {
  display: grid;
  align-content: start;
  gap: var(--sp-3);
  overflow-y: auto;
  padding: var(--sp-1) 2px;
  border-radius: var(--radius-sm);
}
</style>