Start a project

Docs

Documentation

The shell, the sidebar, and the content pieces a docs site is assembled from.

DocShell

A documentation layout: a sticky sidebar and a reading column.

vue
<DocShell>
  <template #aside><DocNav :groups="groups" /></template>
  <ContentDoc />  <!-- or any markdown renderer -->
</DocShell>
DocShell props
PropTypeDefaultDescription
asideslot—
defaultslot—
Source · ads/app/components/DocShell.vue
vue
<script setup lang="ts">
// A documentation layout: a sticky sidebar of sections and a reading column.
// The sidebar is rail-wide, so docs and the dashboard line up; on a phone it
// drops below the reading column, behind a hairline.
</script>

<template>
  <div class="ads-doc-shell">
    <aside class="ads-doc-shell__aside">
      <slot name="aside" />
    </aside>
    <div class="ads-doc-shell__main">
      <slot />
    </div>
  </div>
</template>

<style scoped>
.ads-doc-shell {
  display: grid;
  grid-template-columns: var(--rail-w) minmax(0, 1fr);
  gap: clamp(32px, 5vw, 72px);
  align-items: start;
}

.ads-doc-shell__aside {
  position: sticky;
  top: calc(var(--sp-8) + var(--sp-5));
  align-self: start;
  max-height: calc(100vh - var(--sp-8) - var(--sp-7));
  overflow-y: auto;
  padding: var(--sp-1);
}

.ads-doc-shell__main {
  min-width: 0;
  max-width: 780px;
}

@media (max-width: 900px) {
  .ads-doc-shell {
    grid-template-columns: 1fr;
  }
  .ads-doc-shell__aside {
    position: static;
    max-height: none;
    order: 2;
    padding-top: var(--sp-6);
    border-top: 1px solid var(--line);
  }
}
</style>

DocNav

A help-centre sidebar, pages grouped by section. Prop-driven, so it works with Nuxt Content or a plain array.

vue
<DocNav
  home="{ to: '/ui-kit/docs', label: 'Help centre' }"
  :groups="[{ section: 'Getting started', pages: [{ title: 'Installing', path: '/docs/installing' }] }]" />
DocNav props
PropTypeDefaultDescription
groups{ section, pages: { title, path }[] }[]—
home{ to, label }—
Source · ads/app/components/DocNav.vue
vue
<script setup lang="ts">
// A help-centre sidebar: pages grouped by section. Prop-driven, so it works
// with Nuxt Content or a plain array. The current page is pressed into the
// rail — the shadow inversion, not a colour — and announced with
// aria-current.
export type DocPage = { title: string; path: string }
export type DocGroup = { section: string; pages: DocPage[] }

defineProps<{ groups: DocGroup[]; home?: { to: string; label: string } }>()

const route = useRoute()
const uid = useId()
</script>

<template>
  <nav class="ads-doc-nav" aria-label="Docs">
    <NuxtLink
      v-if="home"
      :to="home.to"
      class="ads-doc-nav__link ads-doc-nav__home"
      :class="{ 'is-active': route.path === home.to }"
      :aria-current="route.path === home.to ? 'page' : undefined"
    >
      {{ home.label }}
    </NuxtLink>
    <div v-for="(g, gi) in groups" :key="g.section" class="ads-doc-nav__group">
      <span :id="`${uid}-${gi}`" class="eyebrow ads-doc-nav__heading">{{ g.section }}</span>
      <ul class="ads-doc-nav__list" role="list" :aria-labelledby="`${uid}-${gi}`">
        <li v-for="p in g.pages" :key="p.path">
          <NuxtLink
            :to="p.path"
            class="ads-doc-nav__link"
            :class="{ 'is-active': route.path === p.path }"
            :aria-current="route.path === p.path ? 'page' : undefined"
          >
            {{ p.title }}
          </NuxtLink>
        </li>
      </ul>
    </div>
  </nav>
</template>

<style scoped>
.ads-doc-nav {
  display: grid;
  gap: var(--sp-5);
  align-content: start;
}

.ads-doc-nav__group {
  display: grid;
}

.ads-doc-nav__heading {
  font-size: 0.64rem;
  margin-bottom: var(--sp-2);
}

.ads-doc-nav__list {
  display: grid;
  gap: 2px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.ads-doc-nav__link {
  display: block;
  padding: var(--sp-2) var(--sp-3);
  border-radius: var(--radius-sm);
  font-size: 0.9rem;
  color: var(--text-soft);
  transition:
    color var(--dur-control) var(--ease),
    box-shadow var(--dur-control) var(--ease);
}

.ads-doc-nav__link:hover {
  color: var(--text);
}

.ads-doc-nav__link.is-active {
  color: var(--text);
  background: var(--surface);
  box-shadow: var(--soft-inset-sm);
}

.ads-doc-nav__home {
  font-weight: 500;
  color: var(--text);
}
</style>

Callout

A docs callout. Every kind is a glyph and a word, never a coloured edge; a warning is the brightest of the three.

Note
A plain note.
Tip
A tip worth knowing.
Warning
This changes how results are read.
vue
<Callout type="tip">A tip worth knowing.</Callout>
<Callout type="warning">This changes how results are read.</Callout>
Callout props
PropTypeDefaultDescription
typenote | tip | warningnote
Source · ads/app/components/Callout.vue
vue
<script setup lang="ts">
// A docs callout. Every kind is a glyph and a word — never a coloured edge.
// In a monochrome system the glyph is what survives a greyscale print, and a
// stripe of hue would be colour carrying meaning on its own. A warning is the
// brightest of the three, because here the worse thing is the brighter one.
const props = withDefaults(defineProps<{ type?: 'note' | 'tip' | 'warning' }>(), { type: 'note' })

const meta = computed(() => {
  switch (props.type) {
    case 'tip':
      return { label: 'Tip', icon: 'spark' }
    case 'warning':
      return { label: 'Warning', icon: 'alert' }
    default:
      return { label: 'Note', icon: 'compass' }
  }
})
</script>

<template>
  <div class="ads-callout" :class="`is-${type}`" role="note">
    <span class="ads-callout__glyph"><Icon :name="meta.icon" :size="16" /></span>
    <div class="ads-callout__content">
      <span class="ads-label ads-callout__label">{{ meta.label }}</span>
      <div class="ads-callout__body"><slot /></div>
    </div>
  </div>
</template>

<style scoped>
.ads-callout {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: var(--sp-3);
  align-items: start;
  padding: var(--sp-4) var(--sp-5);
  border-radius: var(--radius-sm);
  background: var(--raised);
  box-shadow: var(--soft-raised-sm);
}

/* The glyph sits in a small well, so it reads as a mark on the callout rather
   than an icon floating beside the text. */
.ads-callout__glyph {
  display: grid;
  place-items: center;
  width: 30px;
  height: 30px;
  border-radius: var(--pill);
  background: var(--surface);
  box-shadow: var(--soft-inset-sm);
  color: var(--text-soft);
}

.ads-callout.is-warning .ads-callout__glyph,
.ads-callout.is-warning .ads-callout__label {
  color: var(--text);
}

.ads-callout__content {
  display: grid;
  gap: var(--sp-1);
  min-width: 0;
  padding-top: 3px;
}

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

.ads-callout__body :deep(p) {
  margin: 0;
}
</style>

AppWindow

A screenshot in a soft window frame. The traffic dots are grey wells: the window is chrome, so it spends no accent.

The results table
vue
<AppWindow src="/shot.png" alt="The results table" />
AppWindow props
PropTypeDefaultDescription
src / altstring—
width / heightnumber1440 / 900
eagerbooleanfalse
Source · ads/app/components/AppWindow.vue
vue
<script setup lang="ts">
// A screenshot in a soft window frame. The traffic dots are pressed into the
// frame and all three are grey: the window is chrome, and the accent is kept
// for the one action on the page, not spent on a picture of one.
const props = withDefaults(
  defineProps<{ src: string; alt: string; width?: number; height?: number; eager?: boolean }>(),
  { width: 1440, height: 900, eager: false },
)

const el = ref<HTMLElement>()
const shown = ref(false)

onMounted(() => {
  if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
    shown.value = true
    return
  }
  const io = new IntersectionObserver(
    ([e]) => {
      if (e!.intersectionRatio >= 0.12) shown.value = true
      else if (!e!.isIntersecting) shown.value = false
    },
    { threshold: [0, 0.12] },
  )
  io.observe(el.value!)
  onBeforeUnmount(() => io.disconnect())
})
</script>

<template>
  <figure ref="el" class="ads-app-window" :class="{ 'is-shown': shown }">
    <div class="ads-app-window__chrome" aria-hidden="true">
      <span class="ads-app-window__dot" /><span class="ads-app-window__dot" /><span class="ads-app-window__dot" />
    </div>
    <div class="ads-app-window__pane">
      <img
        class="ads-app-window__image"
        :src="src"
        :alt="alt"
        :width="props.width"
        :height="props.height"
        :loading="eager ? 'eager' : 'lazy'"
        :fetchpriority="eager ? 'high' : 'auto'"
        decoding="async"
      />
    </div>
  </figure>
</template>

<style scoped>
.ads-app-window {
  margin: 0;
  padding: 0 var(--sp-3) var(--sp-3);
  border-radius: var(--radius);
  background: var(--raised);
  box-shadow: var(--soft-raised-lg);
}

.ads-app-window__chrome {
  display: flex;
  gap: var(--sp-2);
  padding: var(--sp-3) 6px;
}

.ads-app-window__dot {
  width: 11px;
  height: 11px;
  border-radius: 50%;
  background: var(--surface);
  box-shadow: var(--soft-inset-sm);
}

.ads-app-window__pane {
  position: relative;
  overflow: hidden;
  border-radius: var(--radius-sm);
  background: var(--canvas);
}

.ads-app-window__image {
  display: block;
  width: 100%;
  height: auto;
}

/* Reveal: rise and settle as it scrolls into view. */
.js .ads-app-window {
  opacity: 0;
  translate: 0 40px;
  transition:
    opacity var(--dur-enter) var(--ease),
    translate var(--dur-enter) var(--ease);
}

.js .ads-app-window.is-shown {
  opacity: 1;
  translate: 0 0;
}

@media (prefers-reduced-motion: reduce) {
  .js .ads-app-window {
    opacity: 1;
    translate: none;
    transition: none;
  }
}
</style>

Screenshot

A screenshot figure: the window frame plus an optional caption.

The results table
The results table.
vue
<Screenshot src="/shot.png" alt="The results table" caption="The results table." />
Screenshot props
PropTypeDefaultDescription
src / altstring—
captionstring
Source · ads/app/components/Screenshot.vue
vue
<script setup lang="ts">
// A screenshot figure: the window frame plus an optional caption. The caption
// belongs to this outer figure, so it describes the shot rather than the frame.
defineProps<{ src: string; alt: string; caption?: string }>()
</script>

<template>
  <figure class="ads-screenshot">
    <AppWindow :src="src" :alt="alt" />
    <figcaption v-if="caption" class="ads-screenshot__caption">{{ caption }}</figcaption>
  </figure>
</template>

<style scoped>
.ads-screenshot {
  margin: 2.4em 0;
}

.ads-screenshot__caption {
  margin-top: var(--sp-4);
  text-align: center;
  font-size: 0.86rem;
  color: var(--subdued);
}
</style>