Shared guestbook
This guestbook keeps form drafts local and stores submitted entries in a shared list. The write caps the list at 20 entries so persisted data stays bounded.
- Keep this page open in your normal window.
- Copy the private-window link, then paste it into a private or incognito window.
- Interact in either window and watch the other one update.
Copy the code
Both versions use the same shared data and behavior. The live playground runs the Vanilla HTML version.
Save this as index.html, or open it in the playground to
test and change it.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Shared guestbook</title>
<style>
:root {
color: #1c1c1c;
background: #f4efe5;
font-family: ui-sans-serif, system-ui, sans-serif;
}
* { box-sizing: border-box; }
body { display: grid; min-height: 100vh; margin: 0; padding: 1.5rem; place-items: center; }
main { width: min(40rem, 100%); }
h1 { margin: 0 0 0.35rem; font-size: clamp(2rem, 8vw, 3.5rem); line-height: 1; }
.intro { margin: 0 0 1.25rem; line-height: 1.5; }
body { place-items: start center; }
.guestbook {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);
gap: 1rem;
}
form, ul {
margin: 0;
padding: 1rem;
border: 2px solid #1c1c1c;
background: #ebe4d5;
box-shadow: 4px 4px 0 #1c1c1c;
}
label { display: grid; gap: 0.35rem; margin-bottom: 0.75rem; font-weight: 700; }
textarea, select, button {
padding: 0.65rem;
border: 2px solid #1c1c1c;
font: inherit;
}
textarea, select { width: 100%; background: #fffdf8; }
button {
background: #f3cf58;
box-shadow: 2px 2px 0 #1c1c1c;
cursor: pointer;
font-weight: 700;
}
button:disabled { cursor: default; opacity: 0.5; }
ul { min-height: 14rem; list-style: none; }
li { display: grid; gap: 0.25rem; padding: 0.65rem; background: #fffdf8; }
li + li { margin-top: 0.55rem; }
li strong { color: #274b9e; font-size: 0.78rem; }
.empty { color: #66615b; }
@media (max-width: 36rem) {
.guestbook { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main>
<h1>Shared guestbook</h1>
<p class="intro">Add a short note. The latest 20 entries remain for everyone.</p>
<section id="shared-guestbook" class="guestbook" can-play>
<form data-form>
<label>
Prompt
<select name="prompt">
<option value="building">I'm building…</option>
<option value="learned">I learned…</option>
</select>
</label>
<label>
Your entry
<textarea name="text" maxlength="140" rows="3" placeholder="I'm building…"></textarea>
</label>
<button type="submit" data-submit>Post</button>
</form>
<ul data-entries aria-live="polite"></ul>
</section>
</main>
<script type="module">
import { playhtml } from "playhtml";
import words from "https://esm.sh/profane-words@1.6.0";
const MAX_ENTRIES = 20;
const guestbook = document.getElementById("shared-guestbook");
const prompts = {
building: "I'm building…",
learned: "I learned…",
};
function isProfane(text) {
return words.some((word) =>
new RegExp("\\b" + word + "\\b", "i").test(text),
);
}
guestbook.defaultData = { entries: [] };
guestbook.updateElement = ({ element, data }) => {
const list = element.querySelector("[data-entries]");
const entries = [...data.entries].reverse();
list.replaceChildren(
...(entries.length
? entries.map((entry) => {
const item = document.createElement("li");
const prompt = document.createElement("strong");
const text = document.createElement("span");
prompt.textContent = prompts[entry.prompt];
text.textContent = entry.text;
item.append(prompt, text);
return item;
})
: [Object.assign(document.createElement("li"), {
className: "empty",
textContent: "No entries yet.",
})]),
);
};
guestbook.onClick = (event, { setData }) => {
if (!event.target.closest("[data-submit]")) {
return;
}
event.preventDefault();
const form = guestbook.querySelector("[data-form]");
const prompt = form.elements.prompt;
const text = form.elements.text;
const value = text.value.trim().slice(0, 140);
if (!value || isProfane(value)) {
text.value = "";
return;
}
setData((draft) => {
draft.entries.push({
id: crypto.randomUUID(),
prompt: prompt.value,
text: value,
});
if (draft.entries.length > MAX_ENTRIES) {
draft.entries.splice(0, draft.entries.length - MAX_ENTRIES);
}
});
text.value = "";
};
guestbook.onMount = ({ getElement }) => {
const form = getElement().querySelector("[data-form]");
const prompt = form.elements.prompt;
const text = form.elements.text;
const updatePlaceholder = () => {
text.placeholder = prompts[prompt.value];
};
prompt.addEventListener("change", updatePlaceholder);
return () => {
prompt.removeEventListener("change", updatePlaceholder);
};
};
await playhtml.init({ developmentMode: true });
</script>
</body>
</html>
Start with a React + TypeScript project, install the packages below,
then replace src/App.tsx with the component.
npm install playhtml @playhtml/react profane-words // ABOUTME: Keeps a bounded guestbook shared across connected browsers.
// ABOUTME: Stores form drafts locally and writes entries only on submit.
import { useState, type FormEvent } from "react";
import { PlayProvider, withSharedState } from "@playhtml/react";
import words from "profane-words";
type Prompt = "building" | "learned";
type Entry = { id: string; prompt: Prompt; text: string };
type GuestbookData = { entries: Entry[] };
const MAX_ENTRIES = 20;
const MAX_TEXT = 140;
const PROMPTS: Record<Prompt, string> = {
building: "I'm building…",
learned: "I learned…",
};
function isProfane(text: string): boolean {
return words.some((word) =>
new RegExp("\\b" + word + "\\b", "i").test(text),
);
}
const SharedGuestbook = withSharedState<GuestbookData>(
{
id: "shared-guestbook",
defaultData: { entries: [] },
},
function SharedGuestbookView({ data, setData }) {
const [prompt, setPrompt] = useState<Prompt>("building");
const [draft, setDraft] = useState("");
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const text = draft.trim().slice(0, MAX_TEXT);
if (!text || isProfane(text)) {
setDraft("");
return;
}
setData((shared) => {
shared.entries.push({
id: crypto.randomUUID(),
prompt,
text,
});
if (shared.entries.length > MAX_ENTRIES) {
shared.entries.splice(0, shared.entries.length - MAX_ENTRIES);
}
});
setDraft("");
}
return (
<section id="shared-guestbook" className="guestbook">
<form onSubmit={submit}>
<label>
Prompt
<select
value={prompt}
onChange={(event) => setPrompt(event.target.value as Prompt)}
>
<option value="building">I'm building…</option>
<option value="learned">I learned…</option>
</select>
</label>
<label>
Your entry
<textarea
value={draft}
maxLength={MAX_TEXT}
rows={3}
onChange={(event) => setDraft(event.target.value)}
placeholder={PROMPTS[prompt]}
/>
</label>
<button type="submit" disabled={!draft.trim()}>Post</button>
</form>
<ul aria-live="polite">
{[...data.entries].reverse().map((entry) => (
<li key={entry.id}>
<strong>{PROMPTS[entry.prompt]}</strong>
<span>{entry.text}</span>
</li>
))}
</ul>
</section>
);
},
);
export default function App() {
return (
<PlayProvider initOptions={{ developmentMode: true }}>
<main>
<h1>Shared guestbook</h1>
<p>Add a short note. The latest 20 entries remain for everyone.</p>
<SharedGuestbook />
</main>
<style>{`
:root { color: #1c1c1c; background: #f4efe5; font-family: system-ui, sans-serif; }
* { box-sizing: border-box; }
body { margin: 0; }
#root { min-height: 100vh; padding: 1.5rem; }
main { width: min(42rem, 100%); margin: 0 auto; }
h1 { margin: 0 0 0.35rem; font-size: clamp(2rem, 8vw, 3.5rem); }
main > p { margin: 0 0 1.25rem; }
.guestbook { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); gap: 1rem; }
form, ul {
margin: 0;
padding: 1rem;
border: 2px solid #1c1c1c;
background: #ebe4d5;
box-shadow: 4px 4px 0 #1c1c1c;
}
label { display: grid; gap: 0.35rem; margin-bottom: 0.75rem; font-weight: 700; }
textarea, select, button { padding: 0.65rem; border: 2px solid #1c1c1c; font: inherit; }
textarea, select { width: 100%; background: #fffdf8; }
button { background: #f3cf58; box-shadow: 2px 2px 0 #1c1c1c; cursor: pointer; font-weight: 700; }
button:disabled { cursor: default; opacity: 0.5; }
ul { min-height: 14rem; list-style: none; }
li { display: grid; gap: 0.25rem; padding: 0.65rem; background: #fffdf8; }
li + li { margin-top: 0.55rem; }
li strong { color: #274b9e; font-size: 0.78rem; }
@media (max-width: 36rem) { .guestbook { grid-template-columns: 1fr; } }
`}</style>
</PlayProvider>
);
} Keep drafts local
Section titled “Keep drafts local”The textarea is normal local form state. Nothing is shared until the form passes validation and submits.
Each accepted entry gets a unique id, prompt, and text:
{
id: crypto.randomUUID(),
prompt: "building",
text: "a shared drawing tool",
}
Cap the shared list
Section titled “Cap the shared list”Append and trim in the same mutator:
setData((draft) => {
draft.entries.push(entry);
if (draft.entries.length > 20) {
draft.entries.splice(0, draft.entries.length - 20);
}
});
Use push() and splice() for shared arrays. See
Data essentials for the complete mutation rules.