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).
<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><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.
<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.nameneeds no.value.@submit.preventhandles the event and stops the browser's own submit.v-if/v-else-ifrender 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.
<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>InputandCheckboxare host primitives withv-modelsupport on every target.v-ifandv-foron the same element are an error in Roots (C215): filter with acomputed, 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.
<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.