Draggable Sticky Notes Board with Momentum

A team pin board with seven loose pieces: three sticky notes, a framed photo, a checklist, a ticket stub and a round sticker. Each piece can be picked up, dragged anywhere inside the board and thrown, in which case it slides on and bounces softly off the edges. The piece you touch comes to the front. Pieces pop in one after another with a tilt and then sway slightly as if in a draught, and a button puts everything back. Starting positions are fractions of the free space, so the same layout works on a phone, where the pieces overlap into a looser pile.

Code

// Adapted from Fancy Components (https://fancycomponents.dev/docs/components/blocks/drag-elements)
// Copyright (c) 2024 Daniel Petho. MIT License: https://we.inc/components/licenses
import { useRef, useState } from "react";
import type { KeyboardEvent, ReactNode, RefObject } from "react";
import { motion, useMotionValue, useReducedMotion } from "framer-motion";

const checklist = [
  { text: "Pricing page signed off", done: true },
  { text: "Record the two minute demo", done: true },
  { text: "Email the waiting list", done: false },
  { text: "Order the cake", done: false },
];

// Every piece on the board. `x` and `y` are its starting place as a share of the free space (0 = left or top edge,
// 1 = right or bottom edge), `w` is its width in pixels and `tilt` its resting angle.
const pieces: { id: string; label: string; x: number; y: number; w: number; tilt: number; body: ReactNode }[] = [
  {
    id: "goal",
    label: "Yellow note: the goal",
    x: 0.04,
    y: 0.1,
    w: 190,
    tilt: -5,
    body: (
      <div className="bg-yellow-200 p-5 text-yellow-950 shadow-lg shadow-black/20">
        <p className="font-mono text-[11px] uppercase tracking-widest">The goal</p>
        <p className="mt-2 font-serif text-xl italic leading-snug">Five hundred sign-ups by Friday lunch.</p>
      </div>
    ),
  },
  {
    id: "photo",
    label: "Photo: the team at the whiteboard",
    x: 0.3,
    y: 0.02,
    w: 170,
    tilt: 4,
    body: (
      <div className="bg-white p-3 pb-4 shadow-xl shadow-black/25">
        <div className="relative h-36 overflow-hidden" style={{ background: "linear-gradient(160deg,#fed7aa,#fb7185 55%,#7c3aed)" }}>
          <span className="absolute left-5 top-5 h-10 w-10 rounded-full bg-white/80" />
          <span className="absolute -bottom-8 -left-4 h-20 w-[130%] rounded-[50%] bg-violet-950/70" />
        </div>
        <p className="mt-3 text-center font-serif text-sm italic text-stone-800">Tuesday, 11pm, still smiling</p>
      </div>
    ),
  },
  {
    id: "list",
    label: "Checklist: before we press the button",
    x: 0.62,
    y: 0.08,
    w: 230,
    tilt: 2,
    body: (
      <div className="rounded-lg bg-white p-5 text-stone-900 shadow-xl shadow-black/20">
        <p className="text-sm font-bold">Before we press the button</p>
        <ul className="mt-3 space-y-2 text-sm">
          {checklist.map((c) => (
            <li key={c.text} className="flex items-center gap-2">
              <span className={`flex h-4 w-4 shrink-0 items-center justify-center rounded border ${c.done ? "border-emerald-600 bg-emerald-600 text-white" : "border-stone-400"}`}>
                {c.done && (
                  <svg aria-hidden="true" viewBox="0 0 12 12" className="h-3 w-3" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
                    <path d="M2.5 6.5l2.5 2.5 4.5-5" />
                  </svg>
                )}
              </span>
              <span className={c.done ? "text-stone-500 line-through" : ""}>{c.text}</span>
            </li>
          ))}
        </ul>
      </div>
    ),
  },
  {
    id: "ticket",
    label: "Ticket: launch party",
    x: 0.98,
    y: 0.62,
    w: 220,
    tilt: -7,
    body: (
      <div className="flex bg-stone-900 text-stone-100 shadow-xl shadow-black/30">
        <div className="flex-1 p-4">
          <p className="font-mono text-[10px] uppercase tracking-widest text-amber-300">Admit one</p>
          <p className="mt-1 text-lg font-black uppercase leading-tight">Launch party</p>
          <p className="mt-1 text-xs text-stone-300">Friday, 6pm, the roof</p>
        </div>
        <div className="flex w-12 items-center justify-center border-l-2 border-dashed border-stone-600 font-mono text-xs [writing-mode:vertical-rl]">No. 0417</div>
      </div>
    ),
  },
  {
    id: "quote",
    label: "Pink note: a customer quote",
    x: 0.12,
    y: 0.92,
    w: 200,
    tilt: 6,
    body: (
      <div className="bg-pink-200 p-5 text-pink-950 shadow-lg shadow-black/20">
        <p className="font-serif text-lg italic leading-snug">"I set it up on the train and it just worked."</p>
        <p className="mt-2 font-mono text-[11px] uppercase tracking-widest">Beta tester no. 12</p>
      </div>
    ),
  },
  {
    id: "sticker",
    label: "Sticker: ship it",
    x: 0.5,
    y: 0.66,
    w: 128,
    tilt: -12,
    body: (
      <div className="flex h-32 w-32 items-center justify-center rounded-full border-4 border-white bg-emerald-500 text-center text-white shadow-xl shadow-black/25">
        <p className="text-2xl font-black uppercase leading-none tracking-tight">
          Ship
          <br />
          it
        </p>
      </div>
    ),
  },
  {
    id: "reminder",
    label: "Blue note: a reminder",
    x: 0.72,
    y: 0.98,
    w: 180,
    tilt: -3,
    body: (
      <div className="bg-sky-200 p-5 text-sky-950 shadow-lg shadow-black/20">
        <p className="font-mono text-[11px] uppercase tracking-widest">Do not forget</p>
        <p className="mt-2 font-serif text-xl italic leading-snug">Turn the status page green.</p>
      </div>
    ),
  },
];

const NUDGE = 24; // pixels an arrow key moves the focused piece

function Piece({ piece, index, order, board, reduce, onLift }: { piece: (typeof pieces)[number]; index: number; order: number; board: RefObject<HTMLDivElement>; reduce: boolean; onLift: () => void }) {
  const x = useMotionValue(0);
  const y = useMotionValue(0);
  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    const dx = e.key === "ArrowRight" ? NUDGE : e.key === "ArrowLeft" ? -NUDGE : 0;
    const dy = e.key === "ArrowDown" ? NUDGE : e.key === "ArrowUp" ? -NUDGE : 0;
    if (!dx && !dy) return;
    e.preventDefault();
    onLift();
    x.set(x.get() + dx);
    y.set(y.get() + dy);
  };
  return (
    <motion.div
      drag
      dragConstraints={board}
      dragElastic={0.35}
      dragTransition={{ bounceStiffness: 220, bounceDamping: 24, power: 0.25 }}
      whileDrag={{ scale: 1.06, cursor: "grabbing" }}
      onPointerDown={onLift}
      onFocus={onLift}
      onKeyDown={onKeyDown}
      tabIndex={0}
      role="group"
      aria-label={`${piece.label}. Drag, or use the arrow keys to move.`}
      initial={reduce ? false : { opacity: 0, scale: 0.7, rotate: piece.tilt + 14 }}
      animate={{ opacity: 1, scale: 1, rotate: piece.tilt }}
      transition={{ type: "spring", stiffness: 260, damping: 18, delay: reduce ? 0 : 0.15 + index * 0.09 }}
      className="absolute cursor-grab touch-none select-none focus:outline-none focus-visible:ring-4 focus-visible:ring-indigo-500 focus-visible:ring-offset-2"
      style={{ x, y, width: piece.w, zIndex: order, left: `calc((100% - ${piece.w}px) * ${piece.x})`, top: `calc((100% - 190px) * ${piece.y})` }}
    >
      {/* The inner wrapper sways a little, like paper in a draught. */}
      <div className="sticky-board-sway" style={{ animationDelay: `${-index * 1.3}s`, animationDuration: `${5 + (index % 3)}s` }}>
        {piece.body}
      </div>
    </motion.div>
  );
}

export default function DraggableStickyNotesBoardWithMomentum() {
  const reduce = !!useReducedMotion();
  const board = useRef<HTMLDivElement>(null);
  const [stack, setStack] = useState<string[]>(() => pieces.map((p) => p.id));
  const [round, setRound] = useState(0);
  const lift = (id: string) => setStack((s) => (s[s.length - 1] === id ? s : [...s.filter((v) => v !== id), id]));

  return (
    <section className="bg-stone-100 px-6 py-14 text-stone-900 sm:py-16">
      <style>{`
        @keyframes sticky-board-sway { from { transform: rotate(-1.4deg) translateY(0); } to { transform: rotate(1.4deg) translateY(-3px); } }
        .sticky-board-sway { animation: sticky-board-sway 6s ease-in-out infinite alternate; transform-origin: 50% 0%; }
        @media (prefers-reduced-motion: reduce) { .sticky-board-sway { animation: none; } }
      `}</style>
      <div className="mx-auto max-w-6xl">
        <div className="flex flex-wrap items-end justify-between gap-4">
          <div>
            <p className="font-mono text-xs uppercase tracking-[0.25em] text-stone-600">Paperclip / Team boards</p>
            <h2 className="mt-3 text-4xl font-black tracking-tight sm:text-5xl">Launch week, on one wall.</h2>
          </div>
          <button
            type="button"
            onClick={() => {
              setStack(pieces.map((p) => p.id));
              setRound((r) => r + 1);
            }}
            className="inline-flex h-11 items-center rounded-full border border-stone-400 bg-white px-5 text-sm font-semibold transition hover:border-stone-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-stone-900 focus-visible:ring-offset-2 focus-visible:ring-offset-stone-100"
          >
            Tidy the board
          </button>
        </div>

        <div
          ref={board}
          className="relative mt-8 h-[640px] overflow-hidden rounded-3xl border border-stone-300 bg-[#e7e2d6] sm:h-[560px]"
          style={{ backgroundImage: "radial-gradient(rgba(68,64,60,0.22) 1px, transparent 1px)", backgroundSize: "22px 22px" }}
        >
          <p className="pointer-events-none absolute bottom-4 left-1/2 -translate-x-1/2 whitespace-nowrap rounded-full bg-stone-900/80 px-4 py-2 text-xs font-medium text-white">Drag anything. Throw it and it slides.</p>
          {pieces.map((p, i) => (
            <Piece key={`${p.id}-${round}`} piece={p} index={i} order={stack.indexOf(p.id) + 1} board={board} reduce={reduce} onLift={() => lift(p.id)} />
          ))}
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/draggable-sticky-notes-board-with-momentum.json

When to use it

How to customize it

Accessibility

Questions

How are the pieces kept inside the board?

Every piece passes the board's ref as `dragConstraints`. Framer Motion measures the board and the piece and stops the drag at the edges, with a little elastic give while the pointer is held and a soft bounce if the piece is thrown at an edge.

How does the piece I touch come to the front?

The component keeps an array of ids in stacking order. On pointer down or focus the id is moved to the end of the array, and each piece's `z-index` is its position in that array.

Why are start positions fractions instead of pixels?

A piece placed at 800 pixels from the left would be off screen on a phone. Each piece's `left` is the board width minus its own width, multiplied by `x`, so 0 is always the left edge and 1 always the right edge at any board size.

Can I save where the pieces end up?

Yes. Each `Piece` has `x` and `y` motion values. Read them in an `onDragEnd` handler with `x.get()` and `y.get()` and store them wherever you keep state, then pass them back as initial values.

How does the reset button work?

It increases a counter that is part of each piece's React key. Changing the key remounts the pieces, which clears their drag offsets and replays the entrance.

Adapted from Drag Elements in Fancy Components, used under its open-source licence. Licences and credits

Related: Draggable Card Stack Carousel with Spring Physics, 3D Tilt Card with Parallax Layers and Glare, Spotlight Cards with Pointer Light.

More animated cards or all free UI components. Start for free to have a full site built around it.

More in Animated Cards

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap