Start a project

Inputs

Inputs

Things that take a value. Every field is a well — a raised input reads as something to press.

AdsField

A labelled text input, textarea, select or search. Label above, hint below, error replacing the hint.

We never share it.

Enter a whole number of 1 or more.

vue
<AdsField v-model="email" label="Email" type="email" hint="We never share it." />
<AdsField v-model="msg" label="Message" type="textarea" />
<AdsField v-model="c" label="Country" type="select" :options="[{ label: 'US', value: 'US' }]" />
AdsField props
PropTypeDefaultDescription
labelstring— (required)Always visible. A placeholder is never the label.
modelValuestring | number
typetext | email | password | tel | number | search | date | textarea | selecttextnumber, date and tel are set in the data face; number is right-aligned.
hintstring
errorstringReplaces the hint. Say what to do, not that something is wrong.
options{ label, value }[]—For select.
Source · ads/app/components/AdsField.vue
vue
<script setup lang="ts">
// A labelled field: input, textarea or select, with hint and error.
// Inputs are wells — the card shape pressed into the page. The label is
// required and always visible: a placeholder disappears exactly when it is
// needed, and fails hardest for screen readers. The error replaces the hint
// rather than sitting beside it, and carries a glyph so it is not read by
// colour alone. Figures — numbers, dates, phone numbers — are set in the data
// face.
export type FieldType = 'text' | 'email' | 'password' | 'tel' | 'number' | 'search' | 'date' | 'textarea' | 'select'

const props = withDefaults(
  defineProps<{
    label: string
    modelValue?: string | number
    type?: FieldType
    id?: string
    name?: string
    placeholder?: string
    hint?: string
    error?: string
    options?: { label: string; value: string | number }[]
    rows?: number
    autocomplete?: string
    required?: boolean
    disabled?: boolean
  }>(),
  { type: 'text', id: '', placeholder: '', hint: '', error: '', rows: 4, autocomplete: undefined, required: false, disabled: false },
)

const emit = defineEmits<{ (e: 'update:modelValue', v: string | number): void }>()
const uid = useId()
const fieldId = computed(() => props.id || uid)
const msgId = computed(() => `${fieldId.value}-msg`)
const describedBy = computed(() => (props.error || props.hint ? msgId.value : undefined))
const numeric = computed(() => ['number', 'date', 'tel'].includes(props.type))
</script>

<template>
  <div class="ads-field-group">
    <label :for="fieldId" class="ads-label">{{ label }}</label>

    <textarea
      v-if="type === 'textarea'"
      :id="fieldId"
      :name="name"
      class="ads-field"
      :class="{ 'is-invalid': error }"
      :value="modelValue"
      :placeholder="placeholder"
      :rows="rows"
      :required="required"
      :disabled="disabled"
      :aria-invalid="error ? 'true' : undefined"
      :aria-describedby="describedBy"
      @input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
    />

    <select
      v-else-if="type === 'select'"
      :id="fieldId"
      :name="name"
      class="ads-field"
      :class="{ 'is-invalid': error }"
      :value="modelValue"
      :required="required"
      :disabled="disabled"
      :aria-invalid="error ? 'true' : undefined"
      :aria-describedby="describedBy"
      @change="emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
    >
      <option v-if="placeholder" value="" disabled selected>{{ placeholder }}</option>
      <option v-for="o in options" :key="o.value" :value="o.value">{{ o.label }}</option>
    </select>

    <input
      v-else
      :id="fieldId"
      :name="name"
      :type="type"
      class="ads-field ads-field__input"
      :class="{ 'is-invalid': error, 'is-numeric': numeric, 'is-number': type === 'number' }"
      :value="modelValue"
      :placeholder="placeholder"
      :required="required"
      :disabled="disabled"
      :autocomplete="autocomplete"
      :aria-invalid="error ? 'true' : undefined"
      :aria-describedby="describedBy"
      @input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
    />

    <p v-if="error" :id="msgId" class="ads-hint ads-field__error">
      <Icon name="alert" :size="13" class="ads-field__error-glyph" />
      {{ error }}
    </p>
    <p v-else-if="hint" :id="msgId" class="ads-hint ads-field__hint">{{ hint }}</p>
  </div>
</template>

<style scoped>
.ads-field__input.is-numeric {
  font-family: var(--mono);
  font-variant-numeric: tabular-nums;
}

/* A number lines up with the figures it will be compared against. */
.ads-field__input.is-number {
  text-align: right;
}

.ads-field__hint,
.ads-field__error {
  margin: 0;
}

/* The worse it is, the brighter it is: the error reads at full contrast, and
   the glyph carries the state, so it survives without the colour. */
.ads-field__error {
  display: flex;
  align-items: center;
  gap: var(--sp-1);
  color: var(--text);
}

.ads-field__error-glyph {
  flex: none;
  color: var(--bad);
}
</style>

AdsNumber

A figure with steppers, right-aligned and monospaced so it lines up with the values it will be compared against.

vue
<AdsNumber v-model="maxUrls" :min="1" :max="999" label="Max URLs" />
AdsNumber props
PropTypeDefaultDescription
modelValuenumber0
min / max / stepnumber0 / ∞ / 1
labelstring— (required)Visible; carries the unit, so the field holds only the figure.
Source · ads/app/components/AdsNumber.vue
vue
<script setup lang="ts">
// A stepper: a figure with up/down buttons, right-aligned in the data face so
// it lines up with the values it will be compared against. The label is
// required and visible, and carries the unit — "Max URLs", "Timeout (s)" — so
// the field holds only the figure.
const props = withDefaults(
  defineProps<{ modelValue?: number; min?: number; max?: number; step?: number; label: string }>(),
  { modelValue: 0, min: 0, max: Infinity, step: 1 },
)
const emit = defineEmits<{ (e: 'update:modelValue', v: number): void }>()
const uid = useId()

const clamp = (n: number) => Math.min(Math.max(n, props.min), props.max)

function bump(dir: number) {
  emit('update:modelValue', clamp((props.modelValue ?? 0) + dir * props.step))
}

function onInput(e: Event) {
  const raw = (e.target as HTMLInputElement).value
  // An emptied field is mid-edit, not zero.
  if (raw !== '') emit('update:modelValue', Number(raw))
}
</script>

<template>
  <div class="ads-field-group">
    <label :for="uid" class="ads-label">{{ label }}</label>
    <div class="ads-number">
      <input :id="uid" type="number" :value="modelValue" :min="min" :max="max" :step="step" @input="onInput" />
      <div class="steps">
        <button type="button" :aria-label="`Increase ${label}`" :disabled="modelValue >= max" @click="bump(1)">▲</button>
        <button type="button" :aria-label="`Decrease ${label}`" :disabled="modelValue <= min" @click="bump(-1)">▼</button>
      </div>
    </div>
  </div>
</template>

AdsDaterange

The most-used control on any dashboard. ISO dates in the data face, presets beside them.

to
vue
<AdsDaterange v-model="range" from="2026-09-25" to="2026-10-25" :presets="['7d', '30d', '90d', 'Custom']" />
AdsDaterange props
PropTypeDefaultDescription
from / tostringISO dates
presetsstring[]7d / 30d / 90d / Custom
modelValuestring—
labelstringDate rangeAccessible name of the group.
Source · ads/app/components/AdsDaterange.vue
vue
<script setup lang="ts">
// The most-used control on any dashboard. ISO dates in the data face so
// ranges sort and compare visually, presets beside them because most readers
// want "last 30 days", not a calendar. The whole control is one named group,
// so a screen reader hears what the dates and presets belong to.
withDefaults(
  defineProps<{
    from?: string
    to?: string
    presets?: string[]
    modelValue?: string
    label?: string
  }>(),
  { from: '2026-09-25', to: '2026-10-25', presets: () => ['7d', '30d', '90d', 'Custom'], modelValue: '30d', label: 'Date range' },
)
const emit = defineEmits<{ (e: 'update:modelValue', v: string): void }>()
</script>

<template>
  <div class="stack gap-2" role="group" :aria-label="label">
    <div class="ads-daterange">
      <Icon name="compass" :size="15" />
      <time class="num" :datetime="from">{{ from }}</time>
      <span class="sep" aria-hidden="true">→</span>
      <span class="sr-only">to</span>
      <time class="num" :datetime="to">{{ to }}</time>
    </div>
    <div v-if="presets?.length" class="ads-segmented" role="group" aria-label="Presets">
      <button
        v-for="p in presets"
        :key="p"
        type="button"
        class="num"
        :class="{ 'is-on': modelValue === p }"
        :aria-pressed="modelValue === p"
        @click="emit('update:modelValue', p)"
      >{{ p }}</button>
    </div>
  </div>
</template>

AdsChoice

Three shapes for three meanings: many-of (checkbox), one-of (radio), and a setting that takes effect the moment it is flipped (switch). A switch never sits inside a form with a submit button — that is a checkbox.

vue
<AdsChoice v-model="agree" kind="checkbox" label="Respect robots.txt" />
<AdsChoice v-model="opt" kind="radio" name="opt" value="a" label="Weekly" />
<!-- A switch applies immediately: never inside a form with a submit button. -->
<AdsChoice v-model="live" kind="switch" label="Live updates" />
AdsChoice props
PropTypeDefaultDescription
kindcheckbox | radio | switchcheckbox
labelstring—Visible label; or use the default slot when it needs a link. One of the two is required.
modelValuebooleanfalse
name / valuestring—For radio groups.
Source · ads/app/components/AdsChoice.vue
vue
<script setup lang="ts">
// Checkbox, radio or switch. A switch is for a setting that takes effect
// immediately; a checkbox is for a value that is submitted — so a switch
// inside a <form> is flagged in development: it belongs outside the form, or
// it should be a checkbox. The label is visible and inside the hit target;
// pass `label`, or the default slot when it needs a link.
const props = withDefaults(
  defineProps<{
    kind?: 'checkbox' | 'radio' | 'switch'
    label?: string
    modelValue?: boolean
    name?: string
    value?: string | number
    disabled?: boolean
  }>(),
  { kind: 'checkbox', label: '', modelValue: false, name: undefined, value: undefined, disabled: false },
)

const emit = defineEmits<{ (e: 'update:modelValue', v: boolean): void }>()
const slots = useSlots()
const uid = useId()
const root = ref<HTMLElement>()

function onChange(e: Event) {
  emit('update:modelValue', (e.target as HTMLInputElement).checked)
}

if (false) {
  onMounted(() => {
    if (!props.label && !slots.default) console.warn('[AdsChoice] needs a visible label: pass `label` or the default slot.')
    if (props.kind === 'switch' && root.value?.closest('form')) {
      console.warn('[AdsChoice] A switch applies immediately; inside a form with a submit button, use a checkbox.')
    }
  })
}
</script>

<template>
  <label ref="root" class="ads-choice" :class="{ 'is-disabled': disabled }">
    <input
      :id="uid"
      :type="kind === 'radio' ? 'radio' : 'checkbox'"
      :role="kind === 'switch' ? 'switch' : undefined"
      :name="name"
      :value="value"
      :checked="modelValue"
      :disabled="disabled"
      @change="onChange"
    />
    <span v-if="kind !== 'switch'" class="ads-box" :class="{ 'is-round': kind === 'radio' }">
      <svg v-if="kind === 'checkbox'" viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 12.5l5 5L20 6.5" /></svg>
      <span v-else class="ads-choice__dot" />
    </span>
    <span v-else class="ads-switch" />
    <span class="ads-choice__label"><slot>{{ label }}</slot></span>
  </label>
</template>

<style scoped>
.ads-choice__label {
  color: var(--text-soft);
}

.ads-choice__dot {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: currentColor;
}
</style>

AdsRange

A continuous value where the approximate position matters more than the exact figure — but the figure is shown anyway.

16
vue
<AdsRange v-model="concurrency" :min="1" :max="128" label="Concurrency" />
AdsRange props
PropTypeDefaultDescription
modelValuenumber0
min / max / stepnumber0 / 100 / 1
labelstring— (required)Visible, with the value always read out beside it.
Source · ads/app/components/AdsRange.vue
vue
<script setup lang="ts">
// A slider, pressed into the page, with an accent thumb. For a value where
// the approximate position matters more than the exact figure — but the
// figure is shown anyway, always, in the data face beside the label, because
// somebody always needs it. The label is required and visible; it carries
// the unit so the readout holds only the number.
withDefaults(
  defineProps<{ modelValue?: number; min?: number; max?: number; step?: number; label: string }>(),
  { modelValue: 0, min: 0, max: 100, step: 1 },
)
const emit = defineEmits<{ (e: 'update:modelValue', v: number): void }>()
const uid = useId()
</script>

<template>
  <div class="ads-field-group">
    <div class="ads-range__head">
      <label :for="uid" class="ads-label">{{ label }}</label>
      <output :for="uid" class="num ads-range__value">{{ modelValue }}</output>
    </div>
    <input
      :id="uid"
      type="range"
      class="ads-range"
      :value="modelValue"
      :min="min"
      :max="max"
      :step="step"
      @input="emit('update:modelValue', Number(($event.target as HTMLInputElement).value))"
    />
  </div>
</template>

<style scoped>
.ads-range__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--sp-3);
}

.ads-range__value {
  font-size: 0.82rem;
  color: var(--text);
}
</style>

AdsTag

A filter the reader applied and can take off again. Distinct from a status chip, which cannot be removed — the dismiss affordance is the whole difference.

status: 4xx depth ≤ 3 indexable
vue
<AdsTag v-for="t in filters" :key="t" :label="t" @remove="filters = filters.filter((x) => x !== t)" />
AdsTag props
PropTypeDefaultDescription
labelstring—
@removeevent—Always removable — a chip that cannot be removed is AdsStatus.
Source · ads/app/components/AdsTag.vue
vue
<script setup lang="ts">
// A filter the reader applied and can take off again. Always removable: the
// dismiss affordance is the whole difference from a status chip, which
// describes the data and cannot be removed — for that, use AdsStatus.
defineProps<{ label: string }>()
const emit = defineEmits<{ (e: 'remove'): void }>()
</script>

<template>
  <span class="ads-tag">
    {{ label }}
    <button type="button" :aria-label="`Remove filter ${label}`" @click="emit('remove')">×</button>
  </span>
</template>