Start a project

Feedback & overlays

Feedback & overlays

Empty states, loading, confirmations and the things that float above.

AdsEmpty

The first screen every new user sees, so it gets a component rather than a grey sentence.

No crawls yet

Results, issues and the ranked plan all appear here once a crawl has finished.

No pages match these filters

Nothing in this crawl is both 4xx and indexable.

vue
<!-- Never had data: the action creates something. -->
<AdsEmpty kind="first-run" title="No crawls yet" body="Results appear here once a crawl has finished.">
  <AdsButton variant="accent" icon="plus">New crawl</AdsButton>
</AdsEmpty>

<!-- A filter matched nothing: the action clears it. -->
<AdsEmpty kind="no-results" title="No pages match these filters" body="Nothing in this crawl is both 4xx and indexable.">
  <AdsButton>Clear filters</AdsButton>
</AdsEmpty>
AdsEmpty props
PropTypeDefaultDescription
kindfirst-run | no-resultsfirst-runNever had data, or a filter matched nothing. Different glyph, different copy, different action.
iconstringby kinddiscovery (first-run) / compass (no-results).
titlestring—
bodystring
default slotaction—The action that fills it. Expected — warns in development when missing.
Source · ads/app/components/AdsEmpty.vue
vue
<script setup lang="ts">
// The first screen every new user sees, so it gets a component rather than a
// grey sentence. It teaches what appears here, why it is empty, and carries
// the one action that fixes it in the default slot — not in a toolbar nobody
// has looked at. `kind` keeps the two empties apart: `first-run` (never had
// data: the action creates something) and `no-results` (a filter matched
// nothing: the action clears the filter). Different glyph, different copy.
const props = withDefaults(
  defineProps<{ kind?: 'first-run' | 'no-results'; icon?: string; title: string; body?: string }>(),
  { kind: 'first-run', icon: undefined, body: '' },
)
const slots = useSlots()
const glyph = computed(() => props.icon ?? (props.kind === 'no-results' ? 'compass' : 'discovery'))

if (false && !slots.default) {
  console.warn(`[AdsEmpty] "${props.title}" has no action. An empty state carries the action that fills it.`)
}
</script>

<template>
  <div class="ads-empty">
    <span class="glyph"><Icon :name="glyph" :size="20" /></span>
    <h4>{{ title }}</h4>
    <p v-if="body">{{ body }}</p>
    <slot />
  </div>
</template>

AdsSkeleton

Holds the layout at the real content's dimensions, so nothing shifts when data lands.

vue
<AdsSkeleton width="100%" height="16px" />
AdsSkeleton props
PropTypeDefaultDescription
width / heightstring100% / 1em
pillbooleanfalse
Source · ads/app/components/AdsSkeleton.vue
vue
<script setup lang="ts">
// A placeholder that holds the layout at the real content's dimensions, so
// nothing shifts when the data lands. A spinner over the page throws away a
// layout the reader had already started reading.
withDefaults(defineProps<{ width?: string; height?: string; pill?: boolean }>(), { width: '100%', height: '1em', pill: false })
</script>

<template>
  <div class="ads-skeleton" :class="{ 'round-pill': pill }" :style="{ width, height }" aria-hidden="true" />
</template>

AdsLoading

For the cases a skeleton cannot cover: an action in flight, or a panel whose shape is not known until the data arrives.

Recrawling 311 URLs…
vue
<AdsLoading text="Recrawling 311 URLs…" />
AdsLoading props
PropTypeDefaultDescription
textstring— (required)What is loading. A spinner alone tells nobody anything.
sizenumber18
Source · ads/app/components/AdsLoading.vue
vue
<script setup lang="ts">
// Inline loading, next to the thing that is loading so the rest of the screen
// stays usable. A spinner where the shape is unknown; a skeleton where it is
// known. The text is required: a spinner alone tells a screen reader nothing
// and a sighted reader only that something, somewhere, is busy.
withDefaults(defineProps<{ text: string; size?: number }>(), { size: 18 })
</script>

<template>
  <span class="ads-inline-load" role="status" aria-live="polite">
    <span class="ads-spinner" :style="{ width: `${size}px`, height: `${size}px` }" aria-hidden="true" />
    <slot>{{ text }}</slot>
  </span>
</template>

AdsBanner

A condition that persists, and stays until it is resolved — the place for an error the reader must act on. The glyph and a word carry the state, so it survives without its colour.

This crawl is 14 days old. Figures describe the site as it was on 2026-10-11.
vue
<AdsBanner text="This crawl is 14 days old. Figures describe the site as it was on 2026-10-11." />
<AdsBanner tone="bad" text="The last export failed: the sitemap could not be read.">
  <template #actions><AdsButton size="sm">Retry export</AdsButton></template>
</AdsBanner>
AdsBanner props
PropTypeDefaultDescription
textstring—
toneinfo | warn | badinfobad is announced as an alert.
iconstringby tonesignal (info) / alert (warn, bad).
#actionsslot—The action that resolves it.
Source · ads/app/components/AdsBanner.vue
vue
<script setup lang="ts">
// A banner: a condition that persists, and stays until it is resolved — the
// place for an error the reader must act on, which a toast would carry away
// before it was read. `tone` is a closed set. The glyph and a short word carry
// the state, so it survives without its colour; colour is spent only on the
// glyph, and only for `warn` and `bad`. An error is announced as an alert;
// anything else waits its turn.
type Tone = 'info' | 'warn' | 'bad'

const props = withDefaults(defineProps<{ text: string; tone?: Tone; icon?: string }>(), {
  tone: 'info',
  icon: undefined,
})

const GLYPH: Record<Tone, string> = { info: 'signal', warn: 'alert', bad: 'alert' }
const WORD: Record<Tone, string> = { info: '', warn: 'Warning', bad: 'Action needed' }
const glyph = computed(() => props.icon ?? GLYPH[props.tone])
</script>

<template>
  <div class="ads-banner" :role="tone === 'bad' ? 'alert' : 'status'">
    <span class="glyph ads-banner__glyph" :class="`is-${tone}`"><Icon :name="glyph" :size="17" /></span>
    <span class="ads-banner__body">
      <strong v-if="WORD[tone]" class="ads-banner__tone">{{ WORD[tone] }}</strong>
      {{ text }}
    </span>
    <slot name="actions" />
  </div>
</template>

<style scoped>
.ads-banner__body {
  flex: 1;
  min-width: 0;
}

.ads-banner__tone {
  margin-right: var(--sp-2);
  font-weight: 600;
  color: var(--text);
}

.ads-banner .ads-banner__glyph.is-warn {
  color: var(--warn);
}

.ads-banner .ads-banner__glyph.is-bad {
  color: var(--bad);
}
</style>

AdsToastHost

Confirms something that already happened, then leaves. Mount once, then call useToast().push(text, icon?) from anywhere. Never for an error the reader must act on — that is a banner.

vue
// app.vue
<AdsToastHost />

// anywhere — for something that already happened
const toast = useToast()
toast.push('Export finished · 733 rows', 'check')

// An error the reader must act on is a banner, not a toast:
// useToast() has no error glyph to give it.
AdsToastHost props
PropTypeDefaultDescription
push(text, icon?)icon: check | mail | code | arrow | layerscheckNo error glyph, by design.
Source · ads/app/components/AdsToastHost.vue
vue
<script setup lang="ts">
// The toast host. Mount once (in app.vue) and call useToast().push() anywhere.
// A toast confirms something that already happened and leaves on its own, so
// it never carries an error — useToast() has no error glyph to give it. The
// glyph sits in a small well, pressed into the toast rather than tinted.
const { toasts, dismiss } = useToast()
</script>

<template>
  <Teleport to="body">
    <div class="ads-toast-host" role="status" aria-live="polite">
      <TransitionGroup name="ads-toast-host__toast">
        <button v-for="t in toasts" :key="t.id" type="button" class="ads-toast ads-toast-host__toast" @click="dismiss(t.id)">
          <span class="ads-toast-host__icon"><Icon :name="t.icon" :size="15" /></span>
          {{ t.text }}
        </button>
      </TransitionGroup>
    </div>
  </Teleport>
</template>

<style scoped>
.ads-toast-host {
  position: fixed;
  right: var(--sp-5);
  bottom: var(--sp-5);
  z-index: 110;
  display: grid;
  justify-items: end;
  gap: var(--sp-2);
  pointer-events: none;
}

@media (max-width: 899px) {
  .ads-toast-host {
    right: 0;
    left: 0;
    justify-items: center;
    bottom: calc(96px + env(safe-area-inset-bottom));
  }
}

.ads-toast-host__toast {
  pointer-events: auto;
}

.ads-toast-host__icon {
  display: grid;
  place-items: center;
  width: 24px;
  height: 24px;
  border-radius: 50%;
  background: var(--surface);
  box-shadow: var(--soft-inset-sm);
  flex: none;
}

.ads-toast-host__toast-enter-active,
.ads-toast-host__toast-leave-active {
  transition:
    opacity var(--dur-control) var(--ease),
    transform var(--dur-move) var(--ease);
}

.ads-toast-host__toast-enter-from {
  opacity: 0;
  transform: translateY(var(--sp-3)) scale(0.96);
}

.ads-toast-host__toast-leave-to {
  opacity: 0;
  transform: scale(0.96);
}

.ads-toast-host__toast-move {
  transition: transform var(--dur-move) var(--ease);
}
</style>

AdsModal

Blocks everything until answered, so it is kept for decisions that cannot wait — a destructive confirmation, with the verb on a default-weight button, never the accent. Row detail is a drawer. Closes on Escape and scrim click; focus moves in, stays in, and returns.

vue
const open = ref(false)
<AdsModal v-model:open="open" title="Delete this project?">
  <p>Its crawls, history and comparisons go with it. This cannot be undone.</p>
  <AdsButton destructive @click="remove">Delete project</AdsButton>
  <AdsButton variant="quiet" @click="open = false">Cancel</AdsButton>
</AdsModal>
AdsModal props
PropTypeDefaultDescription
openboolean (v-model:open)false
titlestring— (required)Names the dialog (aria-labelledby).
widthstringCSS max-width override.
Source · ads/app/components/AdsModal.vue
vue
<script setup lang="ts">
// A modal on a scrim. It blocks everything until answered, so it is kept for
// the few moments that cannot proceed without a decision — a destructive
// confirmation, where the verb goes on the button ("Delete project", never
// "OK") and the button stays default weight, never accent. Row detail is a
// drawer, not this. The title is required: it names the dialog for assistive
// tech. Escape and the scrim close it; focus moves in on open, is kept inside
// while open, and goes back to whatever opened it.
const props = withDefaults(defineProps<{ title: string; width?: string }>(), { width: '' })

const open = defineModel<boolean>('open', { default: false })
const panel = ref<HTMLElement>()
const titleId = useId()
let opener: HTMLElement | null = null

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

function onKeydown(e: KeyboardEvent) {
  if (e.key === 'Escape') {
    open.value = false
    return
  }
  if (e.key !== 'Tab' || !panel.value) return
  // Keep Tab inside the panel: the page behind is inert until this is answered.
  const items = [...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 || document.activeElement === panel.value)) {
    e.preventDefault()
    last.focus()
  } else if (!e.shiftKey && document.activeElement === last) {
    e.preventDefault()
    first.focus()
  }
}

watch(open, (v) => {
  if (!false) return
  document.body.style.overflow = v ? 'hidden' : ''
  if (v) {
    opener = document.activeElement as HTMLElement | null
    nextTick(() => panel.value?.focus())
  } else {
    opener?.focus?.()
    opener = null
  }
})

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

<template>
  <Teleport to="body">
    <Transition name="ads-modal__root">
      <div v-if="open" class="ads-modal__root" @keydown="onKeydown">
        <div class="ads-modal__scrim" @click="open = false" />
        <div
          ref="panel"
          class="ads-modal"
          role="dialog"
          aria-modal="true"
          :aria-labelledby="titleId"
          tabindex="-1"
          :style="props.width ? { maxWidth: props.width } : undefined"
          @click.stop
        >
          <header class="ads-modal__head">
            <h3 :id="titleId" class="ads-modal__title">{{ title }}</h3>
            <button type="button" class="ads-modal__close" aria-label="Close" @click="open = false">
              <Icon name="plus" :size="16" class="ads-modal__close-glyph" />
            </button>
          </header>
          <slot />
        </div>
      </div>
    </Transition>
  </Teleport>
</template>

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

.ads-modal__scrim {
  position: absolute;
  inset: 0;
  background: rgb(0 0 0 / 0.55);
  backdrop-filter: blur(3px);
}

.ads-modal {
  position: relative;
}

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

.ads-modal__title {
  font-size: 1.05rem;
}

.ads-modal__close {
  display: grid;
  place-items: center;
  flex: none;
  width: 30px;
  height: 30px;
  border-radius: 50%;
  color: var(--subdued);
  background: var(--surface);
  box-shadow: var(--soft-inset-sm);
  transition: color var(--dur-control) var(--ease);
}

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

/* The close glyph is the plus, turned: one glyph set, no extra path. */
.ads-modal__close-glyph {
  rotate: 45deg;
}

.ads-modal__root-enter-active,
.ads-modal__root-leave-active {
  transition: opacity var(--dur-control) var(--ease);
}

.ads-modal__root-enter-active .ads-modal,
.ads-modal__root-leave-active .ads-modal {
  /* One duration with the scrim, so Vue does not unmount the panel mid-move. */
  transition:
    transform var(--dur-control) var(--ease),
    opacity var(--dur-control) var(--ease);
}

.ads-modal__root-enter-from,
.ads-modal__root-leave-to {
  opacity: 0;
}

.ads-modal__root-enter-from .ads-modal,
.ads-modal__root-leave-to .ads-modal {
  opacity: 0;
  transform: translateY(var(--sp-3)) scale(0.98);
}
</style>

AdsTooltip

Answers a question only if asked: on hover or focus, described to assistive tech, dismissed with Escape. The trigger is the default slot. Never the only way to learn what a control does.

Copies the embed code to the clipboard
vue
<AdsTooltip text="Copies the embed code to the clipboard">
  <AdsIconButton icon="code" label="Copy embed code" />
</AdsTooltip>
AdsTooltip props
PropTypeDefaultDescription
textstring—
positiontop | bottomtop
Source · ads/app/components/AdsTooltip.vue
vue
<script setup lang="ts">
// A tooltip answers a question only if asked: on hover or focus, never on its
// own. The trigger is the default slot; the bubble reads the `text` prop and
// is wired to the first focusable thing in the slot with aria-describedby, so
// it is heard as well as seen. Escape dismisses it without moving focus.
withDefaults(defineProps<{ text: string; position?: 'top' | 'bottom' }>(), { position: 'top' })

const id = useId()
const root = ref<HTMLElement>()
const dismissed = ref(false)

onMounted(() => {
  root.value?.querySelector('a, button, input, select, textarea, [tabindex]')?.setAttribute('aria-describedby', id)
})

function onKeydown(e: KeyboardEvent) {
  if (e.key === 'Escape') dismissed.value = true
}
</script>

<template>
  <span
    ref="root"
    class="ads-tooltip__anchor"
    :class="{ 'is-dismissed': dismissed }"
    @keydown="onKeydown"
    @mouseleave="dismissed = false"
    @focusout="dismissed = false"
  >
    <slot />
    <span :id="id" class="ads-tooltip ads-tooltip__bubble" :class="`is-${position}`" role="tooltip">{{ text }}</span>
  </span>
</template>

<style scoped>
.ads-tooltip__anchor {
  position: relative;
  display: inline-flex;
}

.ads-tooltip__bubble {
  position: absolute;
  left: 50%;
  bottom: calc(100% + var(--sp-2));
  transform: translateX(-50%);
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--dur-control) var(--ease);
  z-index: 30;
}

.ads-tooltip__bubble.is-bottom {
  top: calc(100% + var(--sp-2));
  bottom: auto;
}

.ads-tooltip__anchor:hover .ads-tooltip__bubble,
.ads-tooltip__anchor:focus-within .ads-tooltip__bubble {
  opacity: 1;
}

.ads-tooltip__anchor.is-dismissed .ads-tooltip__bubble {
  opacity: 0;
}
</style>

AdsDrawer

A right-hand detail panel — where a selected row explains itself.

The main table goes here.

vue
<AdsDrawer>
  <AdsDataGrid :columns="columns" :rows="rows" />
  <template #panel><AdsDl :items="detail" /></template>
</AdsDrawer>
Source · ads/app/components/AdsDrawer.vue
vue
<script setup lang="ts">
// A right-hand detail panel. In a dashboard this is where a selected row
// explains itself, so the table never has to leave the screen — which is why
// row detail opens here and never in a modal: a modal hides the very rows the
// reader is comparing against.
//
// The panel is a labelled landmark. Given a `@close` listener it carries a
// close button and Escape closes it; when it opens, focus moves into it, and
// when it closes, focus goes back to whatever opened it (usually the row).
const props = withDefaults(defineProps<{ title?: string; open?: boolean }>(), { title: undefined, open: true })
const emit = defineEmits<{ close: [] }>()

// Only offer a close control when someone is listening for it; a button that
// does nothing is worse than no button.
const closable = !!getCurrentInstance()?.vnode.props?.onClose

const panel = ref<HTMLElement | null>(null)
let opener: HTMLElement | null = null

watch(
  () => props.open,
  async (open) => {
    if (!false) return
    if (open) {
      opener = document.activeElement as HTMLElement | null
      await nextTick()
      panel.value?.focus()
    } else {
      opener?.focus?.()
      opener = null
    }
  },
)

function onKey(e: KeyboardEvent) {
  if (e.key !== 'Escape' || !closable) return
  e.stopPropagation()
  emit('close')
}
</script>

<template>
  <div class="ads-drawer" :class="{ 'is-closed': !open }">
    <div class="ads-drawer__main"><slot /></div>
    <aside
      v-if="open"
      ref="panel"
      class="panel ads-drawer__panel"
      tabindex="-1"
      :aria-label="title ?? 'Detail'"
      @keydown="onKey"
    >
      <div v-if="title || closable" class="ads-drawer__head">
        <span v-if="title" class="ads-label">{{ title }}</span>
        <button v-if="closable" type="button" class="ads-icon-button ads-drawer__close" aria-label="Close detail" @click="emit('close')">
          <Icon name="plus" :size="14" style="transform: rotate(45deg)" />
        </button>
      </div>
      <slot name="panel" />
    </aside>
  </div>
</template>

<style scoped>
.ads-drawer.is-closed {
  grid-template-columns: minmax(0, 1fr);
}

.ads-drawer__panel:focus {
  outline: none;
}

.ads-drawer__panel:focus-visible {
  box-shadow: inset 0 0 0 2px var(--accent);
}

.ads-drawer__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-2);
  min-height: 28px;
}

.ads-drawer__close {
  width: 28px;
  height: 28px;
  margin-left: auto;
}
</style>