ZhuoQi Devzhuoqidev.comRSSllms.txt

← All entries~/design/polaroid-avatar

Cards & widgetsOriginal

Polaroid Avatar

An avatar as a Polaroid: white border, tape, handwriting; on a desk it drags, tilts when fast, and springs back on release.

html · css · svg · jsMITAdded #avatar#polaroid#handmade#drag#spring

Live demoOpen in a new tab ↗

What makes it work

  • Three things are enough: a white photo-paper border (wider at the bottom than on the other three sides), a translucent strip of tape on top, and a line of handwriting below. Whatever photo you put inside looks like a Polaroid.
  • The photo paper is always white: in dark mode the desk gets dark, but the colors of the photo paper and the handwriting are hard-coded, like a real photo lying on a desk at night.
  • The tape is just one gradient: repeating-linear-gradient alternates one yellow between darker and lighter, and tilting it 3 degrees gives it the feel of washi tape.
  • Tilts when dragged: while held it scales up a bit (scale: 1.035), a --tilt is computed from horizontal speed, and CSS tilts with rotate: calc(var(--tilt) * 1deg); after release it is removed, and the linear() spring curve makes it wobble once and settle flat.
  • Resting tilt and drag tilt are separate: the usual 4 degrees is on transform, and the drag tilt is on the separate rotate property, so the two stack without overriding each other, and on spring-back it returns only to 4 degrees.
  • The placeholder avatar is an SVG pencil drawing: feTurbulence + feDisplacementMap add a slight wobble to the lines, and the hair is hatched with a diagonal-line pattern, so no image is needed.

How to use it

  1. Copy the three CSS blocks .polaroid, .tape, .polaroid figcaption and the --spring variable.
  2. Replace the <svg> with your <img> (add draggable="false", otherwise the browser drags out a ghost image).
  3. If you only want a static one (for example on an “About” page): remove the script, touch-action: none and cursor: grab, and adjust the angle in transform to your layout (for example rotate(-3deg)).
  4. For dragging: give the parent container position: relative and the photo position: absolute, and copy the pointerdown script; to remember the position, save left/top to localStorage in up().

Pitfalls

  • Add touch-action: none only on the photo: if it is on the whole container, a finger on blank space can’t scroll the page on phones. A safer approach is to enable dragging only on wide screens and fall back to a normal single column on narrow ones.
  • Without a check like “only movement over 4px counts as a drag”, even a simple click triggers the dragging class and the photo jiggles.
  • Use setPointerCapture for dragging, otherwise if the mouse moves fast and leaves the photo, pointermove is no longer received.
  • The handwriting font uses only system fonts (Kai typefaces on Apple / Windows, AR PL UKai on Linux). For a more handwritten look, add your own font files, and remember to subset Chinese fonts to the characters the page uses, otherwise they are several MB.
$For agents: index.md

Source MIT licensed. Take it.

<!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;
        --seal: #c0391f;
        /* The photo paper stays white in both themes, like a real print. */
        --sheet: #fffdf8;
        --ease: cubic-bezier(0.16, 1, 0.3, 1);
        /* A damped spring sampled into linear(): overshoots about 16%, then settles. */
        --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;
        /* Hand lettering from system fonts only: Kaiti on Mac / Windows, AR PL UKai on Linux. */
        --f-hand: "Kaiti SC", STKaiti, KaiTi, "AR PL UKai CN", "Segoe Print", "Bradley Hand", 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;
          --seal: #f0694e;
          color-scheme: dark;
        }
      }
      * {
        box-sizing: border-box;
      }
      body {
        margin: 0;
        min-height: 100vh;
        background: var(--paper) radial-gradient(var(--dot) 1px, transparent 1.2px) 0 0 / 22px 22px;
        color: var(--ink);
        font: 16px/1.7 var(--f-sans);
      }

      /* ---------- the desk the photo lies on ---------- */
      .desk {
        position: relative;
        height: 100vh;
        min-height: 440px;
        overflow: hidden;
      }
      .hint {
        position: absolute;
        left: 16px;
        right: 16px;
        bottom: 16px;
        display: flex;
        flex-wrap: wrap;
        gap: 8px 14px;
        align-items: center;
        justify-content: center;
        font: 400 12px/1.5 var(--f-mono);
        color: var(--ink-3);
      }
      .hint button {
        font: 500 12px/1 var(--f-mono);
        color: var(--ink-2);
        padding: 7px 11px;
        border: 1px dashed var(--ink-3);
        border-radius: 999px;
        background: transparent;
        cursor: pointer;
        transition: scale 0.45s var(--spring);
      }
      .hint button:hover {
        border-style: solid;
        color: var(--ink);
        background: var(--card);
      }
      .hint button:active {
        scale: 0.93;
      }
      .doodle {
        position: absolute;
        pointer-events: none;
        color: var(--seal);
      }
      .doodle text {
        font: 400 22px var(--f-hand);
        fill: currentColor;
      }

      /* ---------- the polaroid ---------- */
      .polaroid {
        position: absolute;
        width: clamp(160px, 20vw, 260px);
        margin: 0;
        padding: 12px 12px 0;
        background: var(--sheet);
        border-radius: 3px;
        box-shadow:
          0 1px 0 rgba(28, 34, 48, 0.05),
          0 16px 30px -16px rgba(28, 34, 48, 0.45);
        transform: rotate(4deg); /* resting tilt; `rotate` below adds the drag lean on top */
        touch-action: none;
        user-select: none;
        cursor: grab;
        transition:
          scale 0.55s var(--spring),
          rotate 0.7s var(--spring),
          box-shadow 0.25s var(--ease);
      }
      .polaroid:hover {
        scale: 1.04;
      }
      .polaroid.dragging {
        cursor: grabbing;
        scale: 1.035;
        rotate: calc(var(--tilt, 0) * 1deg);
        box-shadow: 0 24px 48px -18px rgba(28, 34, 48, 0.45);
      }
      .polaroid:focus-visible {
        outline: 2px dashed var(--blue);
        outline-offset: 6px;
      }
      .polaroid svg {
        display: block;
        width: 100%;
        height: auto;
        border-radius: 1px;
      }
      .polaroid figcaption {
        padding: 8px 2px 10px;
        text-align: center;
        font: 400 clamp(18px, 1.9vw, 24px) / 1.2 var(--f-hand);
        color: #2b3242; /* fixed: ink on white photo paper in both themes */
      }
      /* A strip of washi tape: two stripes of the same yellow at different strengths. */
      .tape {
        --t: 255, 214, 61;
        position: absolute;
        top: -9px;
        left: 50%;
        width: 78px;
        height: 18px;
        transform: translateX(-50%) rotate(-3deg);
        background: repeating-linear-gradient(90deg, rgba(var(--t), 0.9) 0 5px, rgba(var(--t), 0.45) 5px 10px);
        border-radius: 1px;
      }

      @media (max-width: 520px) {
        .polaroid {
          padding: 10px 10px 0;
        }
      }
      @media (prefers-reduced-motion: reduce) {
        .polaroid,
        .hint button {
          transition: none;
        }
      }
    </style>
  </head>
  <body>
    <main class="desk" id="desk">
      <svg class="doodle" id="doodle" width="150" height="86" viewBox="0 0 150 86" aria-hidden="true">
        <text x="2" y="20">拖我试试</text>
        <path
          d="M20 32 C 50 44, 92 46, 128 74"
          fill="none"
          stroke="currentColor"
          stroke-width="2.2"
          stroke-linecap="round"
        />
        <path
          d="M114 76 L129 75 L124 61"
          fill="none"
          stroke="currentColor"
          stroke-width="2.2"
          stroke-linecap="round"
          stroke-linejoin="round"
        />
      </svg>

      <figure class="polaroid" id="photo" tabindex="0" aria-label="拍立得头像,可拖动;也可以用方向键移动">
        <span class="tape" aria-hidden="true"></span>
        <!-- Placeholder portrait: a pencil doodle drawn in SVG. Swap in your own <img>. -->
        <svg viewBox="0 0 200 200" role="img" aria-label="铅笔涂鸦的小人头像">
          <defs>
            <radialGradient id="bg" cx="50%" cy="38%" r="75%">
              <stop offset="0" stop-color="#f4f2ec" />
              <stop offset="1" stop-color="#d9d5cb" />
            </radialGradient>
            <!-- A little wobble so the lines look drawn by hand, not by a ruler. -->
            <filter id="pencil" x="-5%" y="-5%" width="110%" height="110%">
              <feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" seed="3" />
              <feDisplacementMap in="SourceGraphic" scale="2.2" />
            </filter>
            <pattern id="hatch" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(35)">
              <line x1="0" y1="0" x2="0" y2="6" stroke="#5a5f6b" stroke-width="1" opacity="0.35" />
            </pattern>
          </defs>
          <rect width="200" height="200" fill="url(#bg)" />
          <g
            fill="none"
            stroke="#3d424e"
            stroke-width="2.2"
            stroke-linecap="round"
            stroke-linejoin="round"
            filter="url(#pencil)"
          >
            <!-- shoulders and collar -->
            <path d="M22 204 C 30 168, 62 152, 100 152 C 138 152, 170 168, 178 204" />
            <path d="M84 152 L100 172 L116 152" />
            <!-- neck -->
            <path d="M88 132 L87 152 M112 132 L113 152" />
            <!-- face, drawn twice slightly off like a quick sketch -->
            <path
              d="M58 92 C 56 54, 80 40, 100 40 C 124 40, 144 56, 142 92 C 140 122, 122 140, 100 140 C 78 140, 60 122, 58 92 Z"
            />
            <path
              d="M60 94 C 58 58, 82 43, 101 42 C 122 42, 141 58, 140 94 C 138 120, 121 138, 99 138"
              stroke-width="1"
              opacity="0.55"
            />
            <!-- hair: a scribbled fringe -->
            <path
              d="M56 84 C 52 52, 74 30, 102 30 C 130 30, 150 50, 144 84 C 138 66, 128 58, 118 56 C 108 66, 88 70, 70 64 C 64 70, 60 76, 56 84 Z"
              fill="url(#hatch)"
            />
            <path d="M74 50 C 86 42, 104 40, 122 46 M68 60 C 84 52, 112 50, 134 60" stroke-width="1.2" opacity="0.6" />
            <!-- brows, eyes, nose, smile -->
            <path d="M74 84 C 80 80, 88 80, 92 83 M108 83 C 113 80, 121 80, 126 84" />
            <path d="M80 96 C 82 92, 88 92, 90 96 M110 96 C 112 92, 118 92, 120 96" />
            <path d="M101 100 C 99 108, 97 112, 103 113" stroke-width="1.6" />
            <path d="M86 122 C 94 128, 108 128, 115 121" />
            <!-- cheek shading -->
            <path d="M66 108 C 70 118, 76 126, 84 132" stroke="url(#hatch)" stroke-width="10" opacity="0.8" />
          </g>
        </svg>
        <figcaption>你好,我是小王</figcaption>
      </figure>

      <p class="hint">
        <span>拖拽或方向键移动 · 拖得越快歪得越多</span>
        <button type="button" id="reset">放回原处</button>
      </p>
    </main>

    <script>
      const desk = document.getElementById("desk");
      const photo = document.getElementById("photo");
      const doodle = document.getElementById("doodle");

      // Where the photo rests: a bit right of centre, with the hand-written arrow pointing at it.
      function home() {
        const wide = desk.clientWidth > 600;
        const x = Math.round(desk.clientWidth / 2 - photo.offsetWidth / 2 + (wide ? 90 : 24));
        const y = Math.max(wide ? 40 : 96, Math.round(desk.clientHeight / 2 - photo.offsetHeight / 2 - 24));
        place(x, y);
        doodle.style.left = `${Math.max(4, x - 150)}px`;
        doodle.style.top = `${Math.max(6, y - 40)}px`;
      }
      function place(x, y) {
        const maxX = desk.clientWidth - photo.offsetWidth;
        const maxY = desk.clientHeight - photo.offsetHeight;
        photo.style.left = `${Math.max(0, Math.min(maxX, x))}px`;
        photo.style.top = `${Math.max(0, Math.min(maxY, y))}px`;
      }
      home();
      addEventListener("resize", () => place(photo.offsetLeft, photo.offsetTop));
      document.getElementById("reset").addEventListener("click", home);

      // Drag: pick it up (it grows a little), lean into the direction it moves (the faster sideways,
      // the more it tilts), let go and the CSS spring settles it flat again.
      photo.addEventListener("pointerdown", (e) => {
        if (e.button !== 0) return;
        const startX = e.clientX;
        const startY = e.clientY;
        const ox = photo.offsetLeft;
        const oy = photo.offsetTop;
        let moved = false;
        let lastX = e.clientX;
        let lastT = e.timeStamp;
        let tilt = 0;
        photo.setPointerCapture(e.pointerId);
        const move = (ev) => {
          const dx = ev.clientX - startX;
          const dy = ev.clientY - startY;
          if (!moved && Math.hypot(dx, dy) < 4) return; // a click is not a drag
          moved = true;
          photo.classList.add("dragging");
          const dt = Math.max(8, ev.timeStamp - lastT);
          tilt = tilt * 0.7 + Math.max(-9, Math.min(9, ((ev.clientX - lastX) / dt) * 6)) * 0.3;
          lastX = ev.clientX;
          lastT = ev.timeStamp;
          photo.style.setProperty("--tilt", tilt.toFixed(2));
          place(ox + dx, oy + dy);
        };
        const up = () => {
          photo.removeEventListener("pointermove", move);
          photo.classList.remove("dragging");
          photo.style.removeProperty("--tilt");
        };
        photo.addEventListener("pointermove", move);
        photo.addEventListener("pointerup", up, { once: true });
        photo.addEventListener("pointercancel", up, { once: true });
      });

      // Keyboard: arrow keys nudge it (Shift for bigger steps).
      photo.addEventListener("keydown", (e) => {
        const step = e.shiftKey ? 40 : 10;
        const d = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] }[e.key];
        if (!d) return;
        e.preventDefault();
        place(photo.offsetLeft + d[0], photo.offsetTop + d[1]);
      });
    </script>
  </body>
</html>

Say hi on WeChat

Liu ZhuoQi's WeChat QR code

Scan it in WeChat, or long-press it on your phone

Mention why you're reaching out, or I might miss it.