Skip to the contentSkip to the navigation
RootsGitHubMenu
On this page

Components by example

Each example on this page is one file under docs/demos/: the page imports it as a component and shows the text of the same file. What runs is what you read.

State and derived values #

A ref holds one value, computed derives one. The template updates exactly the text nodes that read them: there is no re-render of the component and no virtual DOM (how rendering works).

A counterstatic previewdemos/counter.vue
0twice that is 0
counter.vue
<script setup lang="ts">
import { computed, ref } from "roots/runtime"

const count = ref(0)
const doubled = computed(() => count.value * 2)
</script>

<template>
  <Box class="counter flex-row items-center gap-3">
    <Button label="−" aria-label="One less" @click="count--" />
    <Text class="counter-value">{{ count }}</Text>
    <Button label="+" aria-label="One more" @click="count++" />
    <Text class="counter-note">twice that is {{ doubled }}</Text>
  </Box>
</template>

<style>
.counter { flex-wrap: wrap; }
.counter-value { min-width: 2ch; text-align: center; font-size: 1.5rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.counter-note { color: var(--roots-muted); }
</style>
A counterstatic previewdemos/counter-lisp.vue
0twice that is 0
counter-lisp.vue
<script setup lang="lisp">
(import "roots/runtime" ref computed)

(defvar count (ref 0))
(defvar doubled (computed (lambda () (* 2 (@ count value)))))
(defun more () (incf (@ count value)))
(defun less () (decf (@ count value)))
</script>

<template>
  <Box class="counter-lisp flex-row items-center gap-3">
    <Button label="−" aria-label="One less" @click="less" />
    <Text class="counter-lisp-value">{{ count }}</Text>
    <Button label="+" aria-label="One more" @click="more" />
    <Text class="counter-lisp-note">twice that is {{ doubled }}</Text>
  </Box>
</template>

<style>
.counter-lisp { flex-wrap: wrap; }
.counter-lisp-value { min-width: 2ch; text-align: center; font-size: 1.5rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.counter-lisp-note { color: var(--roots-muted); }
</style>

A script is TypeScript by default and may be Roots Lisp (lang="lisp"); both compile to the same component. The Lisp-authored components chapter shows components written entirely in Lisp.

Forms: v-model #

v-model binds a field to state in both directions. Modifiers do the small chores: .trim removes surrounding spaces, .number parses, .lazy waits for change.

A sign-up formstatic previewdemos/signup-form.vue
signup-form.vue
<script setup lang="ts">
import { computed, reactive, ref } from "roots/runtime"

const fields = reactive({ name: "", email: "", plan: "free", news: false })
const sent = ref("")
const problem = computed(() =>
  fields.name.trim() === "" ? "Enter a name."
    : !/^\S+@\S+\.\S+$/.test(fields.email) ? "Enter an e-mail address."
    : "")

function submit(): void {
  sent.value = `${fields.name} <${fields.email}>, ${fields.plan} plan${fields.news ? ", with the newsletter" : ""}`
}
</script>

<template>
  <form class="signup-form" @submit.prevent="submit">
    <label>Name <input v-model.trim="fields.name" name="name" autocomplete="off"></label>
    <label>E-mail <input v-model.trim="fields.email" name="email" type="email" autocomplete="off"></label>
    <label>Plan
      <select v-model="fields.plan" name="plan">
        <option value="free">Free</option>
        <option value="team">Team</option>
      </select>
    </label>
    <label class="signup-form-check"><input v-model="fields.news" type="checkbox"> Send me the newsletter</label>
    <button type="submit" :disabled="problem !== ''">Sign up</button>
    <p v-if="problem" class="signup-form-hint">{{ problem }}</p>
    <p v-else-if="sent" class="signup-form-done" role="status">Signed up: {{ sent }}</p>
  </form>
</template>

<style>
.signup-form { display: grid; gap: 10px; max-width: 22rem; }
.signup-form label { display: grid; gap: 3px; font-size: 0.9rem; font-weight: 600; }
.signup-form input:not([type="checkbox"]), .signup-form select { font: inherit; padding: 6px 8px; border: 1px solid var(--roots-control-border);
  border-radius: 6px; background: var(--roots-bg); color: var(--roots-text); }
.signup-form .signup-form-check { display: flex; align-items: center; gap: 8px; font-weight: 400; }
.signup-form button { justify-self: start; font: inherit; font-weight: 600; padding: 6px 14px; border: 0; border-radius: 6px;
  background: var(--roots-primary); color: var(--roots-on-primary); cursor: pointer; }
.signup-form button:disabled { opacity: 0.5; cursor: not-allowed; }
.signup-form-hint { margin: 0; color: var(--roots-muted); }
.signup-form-done { margin: 0; color: var(--roots-accent); font-weight: 600; }
</style>
  • reactive({ … }) makes an object whose properties are tracked: fields.name needs no .value.
  • @submit.prevent handles the event and stops the browser's own submit.
  • v-if / v-else-if render one of the two messages; a removed branch is really removed.

The directives are listed in Templates: directives.

Lists: v-for #

v-for renders one block per item. With a :key the list is updated in place: rows keep their DOM nodes (and their focus) when items are added, removed or reordered.

A todo liststatic previewdemos/todo-list.vue
1 of 2 open
todo-list.vue
<script setup lang="ts">
import { computed, ref } from "roots/runtime"

interface Todo { id: number; text: string; done: boolean }
let nextId = 3
const todos = ref<Todo[]>([
  { id: 1, text: "Read the guide", done: true },
  { id: 2, text: "Build for the terminal", done: false },
])
const draft = ref("")
const open = computed(() => todos.value.filter((todo) => !todo.done).length)

function add(): void {
  if (draft.value === "") return
  todos.value.push({ id: nextId++, text: draft.value, done: false })
  draft.value = ""
}
function remove(id: number): void {
  todos.value = todos.value.filter((todo) => todo.id !== id)
}
</script>

<template>
  <Box class="todo-list gap-2">
    <Box class="flex-row gap-2">
      <Input v-model.trim="draft" placeholder="What needs doing?" aria-label="New todo" @keydown.enter="add" />
      <Button label="Add" @click="add" />
    </Box>
    <Text v-if="todos.length === 0" class="todo-list-empty">Nothing to do.</Text>
    <Box v-for="todo in todos" :key="todo.id" class="todo-list-row flex-row items-center gap-2">
      <Checkbox v-model="todo.done" :label="todo.text" />
      <Spacer />
      <Button label="Remove" :aria-label="'Remove ' + todo.text" @click="remove(todo.id)" />
    </Box>
    <Text class="todo-list-count">{{ open }} of {{ todos.length }} open</Text>
  </Box>
</template>

<style>
.todo-list { max-width: 26rem; }
.todo-list-row { padding: 2px 0; border-bottom: 1px solid var(--roots-border); }
.todo-list-empty, .todo-list-count { color: var(--roots-muted); }
</style>
  • Input and Checkbox are host primitives with v-model support on every target.
  • v-if and v-for on the same element are an error in Roots (C215): filter with a computed, or wrap one of them in a <template>.

Components of the kit #

roots/ui has 40 ready components. They are imported like any other module and work with the keyboard and a screen reader out of the box.

Buttons, badges and tagsstatic previewdemos/kit-buttons.vue
Saved0
stable
beta
0.4
kit-buttons.vue
<script setup lang="ts">
import { ref } from "roots/runtime"
import { Badge, Button, IconButton, Tag } from "roots/ui"

const saving = ref(false)
const saved = ref(0)
function save(): void {
  saving.value = true
  setTimeout(() => { saving.value = false; saved.value++ }, 900)
}
</script>

<template>
  <Box class="kit-buttons gap-3">
    <Box class="flex-row items-center gap-2 wrap">
      <Button label="Save" icon="check" :loading="saving" @click="save" />
      <Button variant="secondary" label="Secondary" />
      <Button variant="ghost" label="Ghost" />
      <Button variant="danger" label="Delete" icon="trash" />
      <IconButton icon="settings" label="Settings" />
    </Box>
    <Box class="flex-row items-center gap-2 wrap">
      <Text>Saved</Text><Badge :value="saved" />
      <Tag variant="success">stable</Tag>
      <Tag variant="warning">beta</Tag>
      <Tag variant="info">0.4</Tag>
    </Box>
  </Box>
</template>

<style>
.kit-buttons { max-width: 40rem; }
</style>

More of them, with every prop: the UI kit and its reference.