Skip to content

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.

Live exampleRoom connecting…
Remix
Test it with another person
  1. Keep this page open in your normal window.
  2. Copy the private-window link, then paste it into a private or incognito window.
  3. 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.

Remix

<!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>

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",
}

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.