Forms That Feel Instant
A guestbook on a slow server that still feels quick: use:enhance with a pending state, snapshots that keep what you typed, and view transitions.
src/routes/about/+page.wisp
<title>About</title>
<h1 class="title">About</h1>
<p>A guestbook that lives in the app's memory. Go back and your note is where you left it.</p>
<p><a href="/">Back to the book</a></p>
<style>
.title {
view-transition-name: title;
}
</style>
src/routes/+page.wisp
---
#[model]
struct Entry {
#[validate(len = 1..=40)]
name: String,
}
static BOOK: Table<Entry> = Table::new();
#[action]
async fn sign(entry: Entry) {
wisp::sleep(Duration::from_millis(700)).await; // a slow server
BOOK.add(entry)?;
redirect("/")
}
---
<title>Guestbook</title>
<h1 class="title">Guestbook</h1>
<p><a href="/about">About this book</a></p>
<form method="post" action="?/sign" use:enhance="submit">
<label>Your name <input name="name" required maxlength="40"></label>
<button disabled={:pending}>{:pending ? 'Sending' : 'Sign'}</button>
</form>
<ul class="list">
{#each BOOK.all() as e}
<li>${e.name}</li>
{:else}
<li class="muted">No one has signed yet.</li>
{/each}
</ul>
<h2>Notes</h2>
<button class="quiet" on:click="open = !open">{:open ? 'Hide' : 'Show'} the Note Pad</button>
<div class="pad" hidden={:!open}>
<label>Kept across Back and Forward <textarea name="note" rows="3"></textarea></label>
</div>
<script>
let pending = false
function submit() {
pending = true
return () => (pending = false)
}
export const snapshot = { capture: () => open, restore: (v) => (open = v) }
</script>
<style>
.title {
view-transition-name: title;
}
.pad {
margin-top: 0.75rem;
}
textarea {
width: 100%;
}
</style>
src/app.css
/* One small stylesheet for every example: light or dark with the system,
violet accent, neutrals tinted toward it. */
:root {
color-scheme: light dark;
--bg: light-dark(oklch(0.99 0.004 295), oklch(0.2 0.014 290));
--panel: light-dark(oklch(1 0 0), oklch(0.24 0.016 290));
--sunk: light-dark(oklch(0.965 0.008 295), oklch(0.17 0.012 290));
--line: light-dark(oklch(0.9 0.012 295), oklch(0.34 0.02 290));
--ink: light-dark(oklch(0.22 0.02 290), oklch(0.96 0.006 295));
--muted: light-dark(oklch(0.48 0.02 290), oklch(0.76 0.016 290));
--accent: light-dark(oklch(0.5 0.21 290), oklch(0.72 0.15 292));
--accent-strong: light-dark(oklch(0.43 0.2 290), oklch(0.8 0.12 292));
--accent-soft: light-dark(oklch(0.95 0.03 295), oklch(0.3 0.06 290));
--on-accent: light-dark(oklch(1 0 0), oklch(0.18 0.02 290));
--warn: light-dark(oklch(0.52 0.17 40), oklch(0.78 0.12 50));
--radius: 0.5rem;
--space: 1rem;
--mono: ui-monospace, "Cascadia Code", Menlo, monospace;
}
:root[data-theme="light"] {
color-scheme: light;
}
:root[data-theme="dark"] {
color-scheme: dark;
}
* {
box-sizing: border-box;
}
body {
max-width: 40rem;
margin: 0 auto;
padding: 1.5rem 1rem 3rem;
background: var(--bg);
color: var(--ink);
font: 400 1rem/1.6 system-ui, -apple-system, "Segoe UI", sans-serif;
-webkit-font-smoothing: antialiased;
}
/* Type */
h1 {
margin: 0 0 1rem;
font-size: clamp(1.5rem, 1.2rem + 1.5vw, 1.875rem);
line-height: 1.15;
letter-spacing: -0.02em;
text-wrap: balance;
}
h2 {
margin: 2rem 0 0.75rem;
font-size: 1.125rem;
letter-spacing: -0.01em;
}
h3 {
margin: 0 0 0.5rem;
font-size: 1rem;
}
p {
margin: 0 0 var(--space);
}
a {
color: var(--accent);
text-underline-offset: 0.2em;
}
a:hover {
color: var(--accent-strong);
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
code {
padding: 0.1em 0.35em;
border-radius: 0.3em;
background: var(--sunk);
font: 0.875em var(--mono);
}
pre {
margin: 0 0 var(--space);
padding: 0.875rem 1rem;
overflow-x: auto;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--sunk);
font: 0.875rem/1.55 var(--mono);
}
pre code {
padding: 0;
background: none;
}
.muted {
color: var(--muted);
}
/* Nav between routes: a header of links, the current one marked. */
header,
nav {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 1.25rem;
margin: 0 0 1.5rem;
padding: 0 0 0.75rem;
border-bottom: 1px solid var(--line);
font-weight: 600;
}
header a,
nav a {
color: var(--muted);
text-decoration: none;
}
header a:hover,
nav a:hover,
header a[aria-current],
nav a[aria-current] {
color: var(--ink);
}
/* Forms: fields stack their label above, the button sits at the end of the row. */
form {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 1rem 0.75rem;
margin: 0 0 1.75rem;
}
/* A lone button with an action is a form of its own: it sits in the line. */
form:has(> button:only-child) {
display: inline-flex;
margin: 0;
}
label {
position: relative;
display: grid;
flex: 1 1 12rem;
gap: 0.375rem;
color: var(--muted);
font-size: 0.875rem;
font-weight: 600;
}
label:has(> input[type="checkbox"]) {
display: flex;
flex: 0 0 auto;
align-items: center;
align-self: center;
}
input,
select,
textarea {
width: 100%;
min-height: 2.75rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
color: var(--ink);
font: inherit;
font-size: 1rem;
font-weight: 400;
transition: border-color 0.15s, box-shadow 0.15s;
}
input:hover,
select:hover,
textarea:hover {
border-color: var(--muted);
}
input:focus,
select:focus,
textarea:focus {
border-color: var(--accent);
outline: none;
box-shadow: 0 0 0 3px var(--accent-soft);
}
input[type="checkbox"] {
width: 1.125rem;
min-height: 0;
accent-color: var(--accent);
}
input[type="file"] {
padding: 0.4rem;
}
[aria-invalid="true"] {
border-color: var(--warn);
}
/* A field's problem sits right under it, so the button beside the field stays put. */
label .problem {
position: absolute;
top: calc(100% + 0.25rem);
}
form:has(.problem) {
row-gap: 2.25rem;
margin-bottom: 2.75rem;
}
.problem {
display: block;
color: var(--warn);
font-size: 0.8125rem;
font-weight: 500;
}
/* Buttons */
button,
.button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.75rem;
padding: 0 1.125rem;
border: 1px solid var(--accent);
border-radius: var(--radius);
background: var(--accent);
color: var(--on-accent);
font: 600 0.9375rem/1 system-ui, sans-serif;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, transform 0.1s;
}
button:hover,
.button:hover {
border-color: var(--accent-strong);
background: var(--accent-strong);
color: var(--on-accent);
}
button:active {
transform: translateY(1px);
}
button:disabled,
button[aria-busy="true"] {
opacity: 0.6;
cursor: progress;
transform: none;
}
button.quiet {
min-height: 2.25rem;
padding: 0 0.75rem;
border-color: var(--line);
background: var(--panel);
color: var(--ink);
font-weight: 500;
}
button.quiet:hover {
border-color: var(--accent);
background: var(--accent-soft);
color: var(--ink);
}
.bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-top: 1rem;
}
/* Lists: plain ones keep their bullets; a .list is rows in a panel. */
ul,
ol {
margin: 0 0 var(--space);
padding-left: 1.25rem;
}
li + li {
margin-top: 0.25rem;
}
ul.list {
margin: 0 0 var(--space);
padding: 0;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
list-style: none;
}
label + ul.list {
margin-top: 0.75rem;
}
ul.list li {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem 0.75rem;
margin: 0;
padding: 0.625rem 0.75rem 0.625rem 1rem;
}
ul.list li + li {
border-top: 1px solid var(--line);
}
/* An empty list says so, quietly, in the middle. */
ul.list li.muted:only-child {
justify-content: center;
padding: 1.5rem 1rem;
background: var(--sunk);
}
/* Tables */
table {
width: 100%;
margin: 0 0 var(--space);
border-collapse: collapse;
font-size: 0.9375rem;
}
th,
td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--line);
text-align: left;
}
th {
color: var(--muted);
font-size: 0.8125rem;
font-weight: 600;
}
hr {
margin: 1.5rem 0;
border: 0;
border-top: 1px solid var(--line);
}
@media (prefers-reduced-motion: reduce) {
* {
transition: none !important;
animation: none !important;
}
}
src/app.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
%wisp.head%
</head>
<body>
%wisp.body%
</body>
</html>
How It Works
src/routes/+page.wisp is a guestbook with a deliberately slow server. The sign action is async and waits 700 ms with wisp::sleep before it adds the entry to the BOOK table.
use:enhance="submit" on the form posts it without a page load. The submit function in the <script> sets pending = true and returns a callback that clears it. {:pending} drives the button, so it says Sending while the server works.
export const snapshot saves and restores whether the note pad is open. The router also keeps what you typed in fields, so Back brings it all back.
view-transition-name: title in both pages lets the heading carry over between / and /about (src/routes/about/+page.wisp).
Try it in the preview: sign the book, then type a note, open About and go Back.