---
title: "Sticky-Note Guestbook Wall"
summary: "A wall of colored sticky notes plus a post form: four colors, slight tilt, tape, straighten on hover; demo notes stay local."
category: "layout (Pages & sections)"
origin: "original"
stack: ["html","css","javascript"]
tags: ["guestbook","sticky notes","handmade","form"]
demo: "https://zhuoqidev.com/design/demos/sticky-guestbook/"
url: "https://zhuoqidev.com/en/design/sticky-guestbook/"
license: "MIT"
added: "2026-10-14"
translation: "https://zhuoqidev.com/design/sticky-guestbook/index.md"
---

# Sticky-Note Guestbook Wall

> A wall of colored sticky notes plus a post form: four colors, slight tilt, tape, straighten on hover; demo notes stay local.

## What makes it work

- **Looks like paper**: the four note colors (yellow / pink / blue / green) and the dark text stay the same in both light and dark themes; paper is paper. The surroundings are a dot-grid paper background, and a strip of tape is stuck on top of the form card.
- **Tilted stably**: each note's tilt angle is computed from its id (`(id * 37) % 7 - 3` degrees), so it tilts the same way however many times you refresh, with no random jumping each time.
- **Straightens on hover**: when the mouse moves over a note it turns upright and lifts 3px, transitioning with the spring curve, which feels a bit like being pressed by a finger.
- **The note just posted**: it drops in from above, scales up slightly, then springs into place; your own note has a dashed border and an "only on this device" marker, and can be torn off.
- **Form details**: the character count counts characters (an emoji counts as 1), and the counter turns red when exceeded; the color choice is hidden radio buttons, so it can also be chosen from the keyboard; content is always inserted into the page with `textContent`, so even typed HTML is only displayed as text.

## How to use it

1. Copy the CSS blocks `.gb-form`, `.gb-wall`, `.gb-note` and the page structure, and swap the color variables for your own.
2. So that it can run on its own, the example stores messages only in the browser's `localStorage`. When connecting to your own site, change `submit` to `fetch` your backend endpoint (POST the content, signature and color), and make the notes on the wall a list fetched from the endpoint; the `card()` render function needs no changes.
3. For a public guestbook, moderating before notes go on the wall is recommended: a just-submitted message first shows only in the author's own browser (the "only on this device" look in the example), and appears on everyone's wall once approved.
4. The backend should validate length and color values again; the front-end limits are only for the user's convenience.

## Pitfalls

- The wall uses `grid` + `align-items: start`, otherwise notes in the same row are stretched to the same height and lose the uneven look of sticky notes.
- The tilt uses the separate `rotate` property rather than `transform`, so the hover `translate` and the entrance animation don't override the tilt.
- Notes need `white-space: pre-wrap` to preserve line breaks, plus `overflow-wrap: anywhere`, otherwise a long URL breaks the note's layout.
- On phones, iOS automatically zooms the page when an input with a font size under 16px gets focus, so all inputs are set to 16px.

## Reference implementation (full source)

Live demo: https://zhuoqidev.com/design/demos/sticky-guestbook/. One file: save it as .html and open it.

### index.html

```html
<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>便利贴留言墙</title>
    <style>
      :root {
        --paper: #fbf8f1;
        --paper-2: #f3eee2;
        --card: #fffdf8;
        --ink: #1c2230;
        --ink-2: #4a5163;
        --ink-3: #636877;
        --line: #e6dfd0;
        --dot: #d8d0bf;
        --blue: #2f5bd3;
        --blue-line: rgba(47, 91, 211, 0.35);
        --seal: #c0391f;
        --shadow: 0 1px 0 rgba(28, 34, 48, 0.04), 0 10px 28px -14px rgba(28, 34, 48, 0.28);
        --ease: cubic-bezier(0.16, 1, 0.3, 1);
        --spring: linear(
          0,
          0.062,
          0.215,
          0.411,
          0.615,
          0.799,
          0.95,
          1.059,
          1.126,
          1.158,
          1.161,
          1.145,
          1.116,
          1.083,
          1.051,
          1.023,
          1.001,
          0.986,
          0.977,
          0.974,
          0.974,
          0.978,
          0.983,
          0.988,
          0.994,
          0.998,
          1.001,
          1.003,
          1.004,
          1.004,
          1
        );
        --f-sans: system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
        --f-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
        --f-hand: "Kaiti SC", STKaiti, KaiTi, "AR PL UKai CN", cursive;
        color-scheme: light;
      }
      @media (prefers-color-scheme: dark) {
        :root {
          --paper: #14161b;
          --paper-2: #1c1f26;
          --card: #1e2129;
          --ink: #ece7dc;
          --ink-2: #bab6ac;
          --ink-3: #959aa8;
          --line: #31353f;
          --dot: #2c3039;
          --blue: #8eaaff;
          --blue-line: rgba(142, 170, 255, 0.45);
          --seal: #f0694e;
          --shadow: 0 1px 0 rgba(0, 0, 0, 0.25), 0 10px 28px -14px rgba(0, 0, 0, 0.8);
          color-scheme: dark;
        }
      }
      * {
        box-sizing: border-box;
      }
      body {
        margin: 0;
        background: var(--paper) radial-gradient(var(--dot) 1px, transparent 1.2px) 0 0 / 22px 22px;
        color: var(--ink);
        font: 15px/1.7 var(--f-sans);
      }
      .sr-only {
        position: absolute;
        width: 1px;
        height: 1px;
        overflow: hidden;
        clip: rect(0 0 0 0);
        white-space: nowrap;
      }
      main {
        max-width: 1040px;
        margin: 0 auto;
        padding: 36px 24px 64px;
      }

      /* ---------- page head ---------- */
      .crumb {
        margin-bottom: 12px;
        font: 400 12px/1 var(--f-mono);
        color: var(--ink-3);
      }
      .crumb b {
        color: var(--seal);
        font-weight: 400;
      }
      h1 {
        margin: 0;
        font: 400 46px/1.1 var(--f-hand);
        color: var(--blue);
      }
      .intro {
        margin: 8px 0 26px;
        max-width: 640px;
        color: var(--ink-2);
      }

      /* ---------- the form: a card with a strip of tape ---------- */
      .card {
        background: var(--card);
        border: 2px dashed var(--blue-line);
        border-radius: 14px;
        box-shadow: var(--shadow);
      }
      .tape {
        position: absolute;
        top: -9px;
        left: 50%;
        width: 78px;
        height: 18px;
        transform: translateX(-50%) rotate(-3deg);
        background: repeating-linear-gradient(90deg, rgba(255, 214, 61, 0.9) 0 5px, rgba(255, 214, 61, 0.45) 5px 10px);
        border-radius: 1px;
      }
      .gb-form {
        position: relative;
        display: grid;
        gap: 12px;
        padding: 20px 20px 14px;
      }
      .gb-form textarea {
        width: 100%;
        min-height: 84px;
        resize: vertical;
        padding: 10px 14px;
        border-radius: 10px;
        border: 1px solid var(--line);
        background: var(--paper);
        color: var(--ink);
        font: 400 16px/1.6 var(--f-sans);
      }
      .gb-form textarea:focus-visible,
      .gb-name input:focus-visible {
        outline: 2px solid var(--blue);
        outline-offset: 1px;
      }
      .gb-row {
        display: flex;
        flex-wrap: wrap;
        align-items: center;
        gap: 12px 16px;
      }
      .gb-name {
        display: flex;
        align-items: center;
        gap: 8px;
        font: 400 13px/1 var(--f-mono);
        color: var(--ink-2);
      }
      .gb-name input {
        width: 11em;
        padding: 7px 10px;
        border-radius: 8px;
        border: 1px solid var(--line);
        background: var(--paper);
        color: var(--ink);
        font: 400 16px/1.2 var(--f-sans);
      }
      .gb-colors {
        display: flex;
        gap: 8px;
        margin: 0;
        padding: 0;
        border: 0;
      }
      .gb-swatch {
        position: relative;
        width: 26px;
        height: 26px;
        border-radius: 6px;
        cursor: pointer;
        box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.14);
      }
      .gb-swatch input {
        position: absolute;
        inset: 0;
        margin: 0;
        opacity: 0;
        cursor: pointer;
      }
      .gb-swatch:has(input:checked) {
        outline: 2px solid var(--ink);
        outline-offset: 2px;
      }
      .gb-swatch:has(input:focus-visible) {
        outline: 2px dashed var(--blue);
        outline-offset: 2px;
      }
      .gb-count {
        margin-left: auto;
        font: 400 12px/1 var(--f-mono);
        color: var(--ink-3);
      }
      .gb-count.over {
        color: var(--seal);
      }
      .gb-send {
        padding: 9px 18px;
        border: 1px solid var(--blue);
        border-radius: 10px;
        background: transparent;
        color: var(--blue);
        font: 500 14px/1 var(--f-sans);
        cursor: pointer;
        transition: scale 0.45s var(--spring);
      }
      .gb-send:hover {
        background: var(--paper-2);
      }
      .gb-send:active {
        scale: 0.93;
      }
      .gb-status {
        margin: -4px 0 0;
        min-height: 1.4em;
        font: 500 13px/1.4 var(--f-sans);
        color: var(--ink-2);
      }
      .gb-fine {
        margin: 0;
        font: 400 12px/1.6 var(--f-mono);
        color: var(--ink-3);
      }

      /* ---------- the wall ---------- */
      /* Notes are paper: the same light colours and dark ink in both themes. */
      .gb-wall-h {
        margin: 34px 4px 18px;
        font: 400 28px/1.2 var(--f-hand);
        color: var(--seal);
      }
      .gb-wall {
        list-style: none;
        margin: 0;
        padding: 0;
        display: grid;
        grid-template-columns: repeat(auto-fill, minmax(210px, 1fr));
        gap: 22px;
        align-items: start;
      }
      .gb-note,
      .gb-swatch.yellow {
        background: #ffe88a;
      }
      .gb-note.pink,
      .gb-swatch.pink {
        background: #ffd4dd;
      }
      .gb-note.blue,
      .gb-swatch.blue {
        background: #d5e6ff;
      }
      .gb-note.green,
      .gb-swatch.green {
        background: #d6f1cf;
      }
      /* Each note leans a little (--tilt is stable per note) and straightens up under the pointer. */
      .gb-note {
        --tilt: 0deg;
        position: relative;
        padding: 20px 18px 14px;
        border-radius: 4px;
        rotate: var(--tilt);
        color: #2b2410;
        box-shadow: 0 16px 24px -16px rgba(80, 60, 0, 0.45);
        transition:
          rotate 0.45s var(--spring),
          translate 0.45s var(--spring);
      }
      .gb-note:hover {
        rotate: 0deg;
        translate: 0 -3px;
      }
      /* A short strip of see-through tape on every other note. */
      .gb-note:nth-child(odd)::before {
        content: "";
        position: absolute;
        top: -8px;
        left: 50%;
        width: 56px;
        height: 16px;
        transform: translateX(-50%) rotate(2deg);
        background: rgba(255, 255, 255, 0.55);
        box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.04);
      }
      .gb-note p {
        margin: 0;
        white-space: pre-wrap;
        overflow-wrap: anywhere;
        font: 400 18px/1.55 var(--f-hand);
      }
      .gb-foot {
        display: flex;
        flex-wrap: wrap;
        justify-content: space-between;
        gap: 10px;
        margin-top: 12px;
        font: 400 11px/1.5 var(--f-mono);
        color: #5c5232;
      }
      .gb-note.mine {
        outline: 2px dashed rgba(43, 36, 16, 0.35);
        outline-offset: -6px;
      }
      .gb-mine {
        display: flex;
        justify-content: space-between;
        align-items: baseline;
        margin-bottom: 8px;
        font: 500 11px/1.4 var(--f-mono);
        color: #8a3a22;
      }
      .gb-mine button {
        padding: 0;
        border: 0;
        background: none;
        color: inherit;
        font: inherit;
        text-decoration: underline dashed;
        cursor: pointer;
      }
      .gb-note.new {
        animation: stick 0.6s var(--spring);
      }
      @keyframes stick {
        from {
          opacity: 0;
          scale: 1.15;
          translate: 0 -18px;
        }
      }
      .gb-msg {
        grid-column: 1 / -1;
        font: 400 13px/1.6 var(--f-mono);
        color: var(--ink-3);
      }

      @media (max-width: 560px) {
        main {
          padding: 28px 16px 48px;
        }
        h1 {
          font-size: 38px;
        }
        .gb-form {
          padding: 18px 14px 12px;
        }
        .gb-name input {
          width: 9em;
        }
        .gb-wall {
          grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
          gap: 18px;
        }
      }
      /* Reduced motion keeps the resting tilt (it does not move) and drops the swings. */
      @media (prefers-reduced-motion: reduce) {
        .gb-note {
          transition: none;
        }
        .gb-note.new {
          animation: none;
        }
        .gb-send {
          transition: none;
        }
      }
    </style>
  </head>
  <body>
    <main>
      <div class="crumb">~/guestbook/ <b>ls notes/</b></div>
      <h1>✦ 留言板</h1>
      <p class="intro">随手贴张便签：读后感、想看的题目、发现的错别字，或者就打个招呼。</p>

      <form class="gb-form card" data-max="200">
        <span class="tape" aria-hidden="true"></span>
        <label class="sr-only" for="gb-body">便签内容</label>
        <textarea id="gb-body" name="body" rows="3" required placeholder="想说点什么？"></textarea>
        <div class="gb-row">
          <label class="gb-name">
            <span>署名</span>
            <input name="name" maxlength="24" autocomplete="nickname" placeholder="可以不填" />
          </label>
          <fieldset class="gb-colors">
            <legend class="sr-only">便签颜色</legend>
            <label class="gb-swatch yellow"
              ><input type="radio" name="color" value="yellow" checked /><span class="sr-only">黄色</span></label
            >
            <label class="gb-swatch pink"
              ><input type="radio" name="color" value="pink" /><span class="sr-only">粉色</span></label
            >
            <label class="gb-swatch blue"
              ><input type="radio" name="color" value="blue" /><span class="sr-only">蓝色</span></label
            >
            <label class="gb-swatch green"
              ><input type="radio" name="color" value="green" /><span class="sr-only">绿色</span></label
            >
          </fieldset>
          <span class="gb-count" data-count>0 / 200</span>
          <button class="gb-send" type="submit">贴上去</button>
        </div>
        <p class="gb-status" role="status" aria-live="polite" data-status></p>
        <p class="gb-fine">演示版：便签只存在你这台设备的浏览器里，别人看不到。</p>
      </form>

      <h2 class="gb-wall-h">墙上的便签</h2>
      <ul class="gb-wall" data-wall></ul>
    </main>

    <script>
      // The wall shows sample notes plus the ones posted from this browser (kept in localStorage).
      // In a real site the form would POST to your backend, and approved notes would come back from
      // it; the rendering below stays the same.
      const SAMPLES = [
        { id: 1, name: "路过的读者", body: "第一次来，便利贴的颜色好舒服。", color: "yellow", date: "2026-10-12" },
        {
          id: 2,
          name: "小林",
          body: "照着那篇按钮回弹的文章改了一下，\n我们产品的按钮手感好多了，谢谢！",
          color: "pink",
          date: "2026-10-11",
        },
        {
          id: 3,
          name: "细心的人",
          body: "发现一个错别字：第三段的「布局」写成了「布据」。",
          color: "blue",
          date: "2026-10-10",
        },
        { id: 4, name: "小周", body: "能不能出一期讲深色模式配色的？", color: "green", date: "2026-10-09" },
        { id: 5, name: "", body: "周末照着做了一个桌面风首页，拖卡片太好玩了。", color: "yellow", date: "2026-10-08" },
        {
          id: 6,
          name: "远方的朋友",
          body: "Hello from the other side of the world 👋",
          color: "blue",
          date: "2026-10-06",
        },
        { id: 7, name: "小王", body: "胶带这个小细节好可爱。", color: "pink", date: "2026-10-05" },
        { id: 8, name: "阿杰", body: "在手机上看也很顺，排得整整齐齐。", color: "green", date: "2026-10-03" },
      ];
      const COLORS = ["yellow", "pink", "blue", "green"];
      const KEY = "sticky-guestbook-mine";

      const form = document.querySelector(".gb-form");
      const wall = document.querySelector("[data-wall]");
      const status = document.querySelector("[data-status]");
      const count = document.querySelector("[data-count]");
      const body = form.querySelector("textarea");
      const max = Number(form.dataset.max);

      // localStorage can be missing or throw (private mode, blocked site data); every access is guarded.
      const mine = () => {
        try {
          const list = JSON.parse(localStorage.getItem(KEY) ?? "[]");
          return Array.isArray(list) ? list : [];
        } catch {
          return [];
        }
      };
      const keepMine = (list) => {
        try {
          if (list.length) localStorage.setItem(KEY, JSON.stringify(list.slice(0, 20)));
          else localStorage.removeItem(KEY);
        } catch {}
      };

      // Count code points, so an emoji counts as one character.
      const length = (s) => [...s.trim()].length;
      const today = () => {
        const d = new Date();
        return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
      };
      const say = (text) => {
        status.textContent = text;
      };
      const updateCount = () => {
        const n = length(body.value);
        count.textContent = `${n} / ${max}`;
        count.classList.toggle("over", n > max);
      };
      body.addEventListener("input", updateCount);

      // A slight, stable tilt per note, like paper stuck on by hand.
      const tilt = (id) => ((((id * 37) % 7) + 7) % 7) - 3;

      // Text goes in with textContent, never innerHTML: whatever people type stays text.
      function card(n, own, fresh) {
        const li = document.createElement("li");
        li.className = `gb-note ${COLORS.includes(n.color) ? n.color : "yellow"}${own ? " mine" : ""}${fresh ? " new" : ""}`;
        li.style.setProperty("--tilt", `${tilt(n.id)}deg`);
        if (own) {
          const tag = document.createElement("span");
          tag.className = "gb-mine";
          tag.textContent = "只在本机";
          const rip = document.createElement("button");
          rip.type = "button";
          rip.textContent = "撕掉";
          rip.addEventListener("click", () => {
            keepMine(mine().filter((m) => m.id !== n.id));
            render();
            say("撕掉了。");
          });
          tag.append(rip);
          li.append(tag);
        }
        const p = document.createElement("p");
        p.textContent = n.body;
        const foot = document.createElement("div");
        foot.className = "gb-foot";
        const who = document.createElement("span");
        who.textContent = `— ${n.name || "匿名"}`;
        const when = document.createElement("time");
        when.dateTime = n.date;
        when.textContent = n.date;
        foot.append(who, when);
        li.append(p, foot);
        return li;
      }

      function render(freshId) {
        const own = mine();
        wall.replaceChildren(
          ...own.map((n) => card(n, true, n.id === freshId)),
          ...SAMPLES.map((n) => card(n, false, false)),
        );
      }
      render();

      form.addEventListener("submit", (e) => {
        e.preventDefault();
        const fd = new FormData(form);
        const text = String(fd.get("body") ?? "").trim();
        const name = String(fd.get("name") ?? "")
          .replace(/\s+/g, " ")
          .trim();
        const color = String(fd.get("color") ?? "yellow");
        if (!length(text)) {
          say("先写点什么再贴吧。");
          body.focus();
          return;
        }
        if (length(text) > max) {
          say(`太长了，最多 ${max} 个字。`);
          return;
        }
        const note = { id: Date.now(), name, body: text, color, date: today() };
        keepMine([note, ...mine()]);
        form.reset();
        updateCount();
        render(note.id);
        say("贴好了！它只存在你这台设备的浏览器里。");
      });
    </script>
  </body>
</html>
```
