---
title: "拍立得头像"
summary: "头像做成一张拍立得：白边、一条胶带、一行手写字；放在桌面上可以拖，拖得快会歪，松手弹回。"
category: "card (卡片与小部件)"
origin: "original"
stack: ["html","css","svg","js"]
tags: ["头像","拍立得","手作感","拖拽","弹簧"]
demo: "https://zhuoqidev.com/design/demos/polaroid-avatar/"
url: "https://zhuoqidev.com/design/polaroid-avatar/"
license: "MIT"
added: "2026-10-14"
translation: "https://zhuoqidev.com/en/design/polaroid-avatar/index.md"
---

# 拍立得头像

> 头像做成一张拍立得：白边、一条胶带、一行手写字；放在桌面上可以拖，拖得快会歪，松手弹回。

## 看点

- **三样东西就成立**：白色相纸边（底边留得比三边宽）、顶上一条半透明胶带、底下一行手写字。里面换成什么照片都像拍立得。
- **相纸永远是白的**：深色模式下桌面变暗，但相纸和手写字的颜色写死，像一张真的照片放在夜里的桌上。
- **胶带只用一个渐变**：`repeating-linear-gradient` 让同一种黄一深一浅地交替，再歪 3 度，就有和纸胶带的质感。
- **拖起来会歪**：按住时放大一点（`scale: 1.035`），按横向速度算出一个 `--tilt`，CSS 用 `rotate: calc(var(--tilt) * 1deg)` 歪过去；松手后去掉，`linear()` 弹簧曲线让它晃一下再停平。
- **静止的歪和拖动的歪分开**：平时的 4 度写在 `transform` 上，拖动的倾斜写在独立的 `rotate` 属性上，两者叠加互不覆盖，所以弹回时只回到 4 度。
- **占位头像是 SVG 铅笔画**：`feTurbulence` + `feDisplacementMap` 给线条加一点抖动，头发用斜线 `pattern` 排线，不需要任何图片。

## 怎么用

1. 拷 `.polaroid`、`.tape`、`.polaroid figcaption` 三段 CSS 和 `--spring` 变量。
2. 把 `<svg>` 换成你的 `<img>`（加 `draggable="false"`，否则浏览器会拖出图片的幽灵影子）。
3. 只想要静态的（比如「关于」页）：去掉脚本和 `touch-action: none`、`cursor: grab`，`transform` 里的角度按版面改（比如 `rotate(-3deg)`）即可。
4. 要拖动：父容器 `position: relative`，相片 `position: absolute`，把 `pointerdown` 那段脚本拷过去；需要记住位置就在 `up()` 里把 `left/top` 存进 `localStorage`。

## 坑

- `touch-action: none` 一定只加在相片上：加在整个容器上，手机上手指放在空白处也滚不动页面。更稳妥的做法是只在宽屏开启拖动，窄屏退回普通的一列。
- 不判断「移动超过 4px 才算拖」的话，点一下也会触发 `dragging` 类，相片会抖一下。
- 拖动用 `setPointerCapture`，否则鼠标甩快了离开相片，`pointermove` 就收不到了。
- 手写字体只用系统自带（苹果 / Windows 的楷体，Linux 的 AR PL UKai）。想要更像手写就自己加字体文件，中文字体记得按页面用到的字切子集，不然几 MB。

## 参考实现（完整源码）

在线演示：https://zhuoqidev.com/design/demos/polaroid-avatar/。单文件，存成 .html 就能打开。

### 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;
        --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>
```
