Tangram

No dependencies
Code format
React + Tailwind
import { useRef, useState, type CSSProperties } from 'react';

type P = [number, number];
const UNIT = 25;
const S2 = Math.SQRT2;

// The seven pieces at their own size. Triangles list the long side first, so a
// target polygon can be matched to its piece by its first edge.
const BASE: P[][] = [
  [[0, 0], [4, 0], [2, 2]],
  [[0, 0], [4, 0], [2, 2]],
  [[0, 0], [2 * S2, 0], [S2, S2]],
  [[0, 0], [2, 0], [1, 1]],
  [[0, 0], [2, 0], [1, 1]],
  [[0, 0], [S2, 0], [S2, S2], [0, S2]],
  [[0, 0], [2, 0], [3, 1], [1, 1]],
];

const FIGURES: { name: string; pieces: P[][] }[] = [
  {
    name: 'Square',
    pieces: [
      [[0, 0], [4, 0], [2, 2]],
      [[0, 4], [0, 0], [2, 2]],
      [[2, 4], [4, 2], [4, 4]],
      [[4, 0], [4, 2], [3, 1]],
      [[1, 3], [3, 3], [2, 2]],
      [[2, 2], [3, 1], [4, 2], [3, 3]],
      [[0, 4], [2, 4], [3, 3], [1, 3]],
    ],
  },
  {
    name: 'Sailboat',
    pieces: [
      [[0.06, -2.83], [2.89, 0], [0.06, 0]],
      [[-2, 0.16], [2, 0.16], [0, 2.16]],
      [[-0.06, -2], [-2.06, 0], [-0.06, 0]],
      [[-3.2, 3.3], [-1.2, 3.3], [-2.2, 2.3]],
      [[1.2, 3.3], [3.2, 3.3], [2.2, 2.3]],
      [[-2.4, -3.4], [-1.4, -2.4], [-2.4, -1.4], [-3.4, -2.4]],
      [[-1, 3.3], [1, 3.3], [2, 2.3], [0, 2.3]],
    ],
  },
  {
    name: 'Cat',
    pieces: [
      [[-1, 1.05], [-1, 5.05], [1, 3.05]],
      [[3, 5.05], [-1, 5.05], [1, 3.05]],
      [[-1.05, 3.05], [-3.05, 5.05], [-1.05, 5.05]],
      [[-2, 0], [-1, -1], [-2, -1]],
      [[-1, -1], [0, 0], [0, -1]],
      [[-1, -1], [0, 0], [-1, 1], [-2, 0]],
      [[3.05, 5.05], [4.464, 3.636], [4.464, 2.222], [3.05, 3.636]],
    ],
  },
  {
    name: 'Fish',
    pieces: [
      [[0, 2], [0, -2], [2, 0]],
      [[0, -2], [0, 2], [-2, 0]],
      [[-3.42, -1.42], [-3.42, 1.42], [-2, 0]],
      [[0, -2.05], [-1.41, -0.64], [-1.41, -2.05]],
      [[-1.41, 0.64], [0, 2.05], [-1.41, 2.05]],
      [[2.6, -2.4], [3.6, -1.4], [2.6, -0.4], [1.6, -1.4]],
      [[1.4, 2], [3.4, 2], [4.4, 1], [2.4, 1]],
    ],
  },
];

const centroid = (poly: P[]): P => {
  const sum = poly.reduce<P>((acc, [x, y]) => [acc[0] + x, acc[1] + y], [0, 0]);
  return [sum[0] / poly.length, sum[1] / poly.length];
};
const area = (poly: P[]) => poly.reduce((acc, [x, y], i) => {
  const [nx, ny] = poly[(i + 1) % poly.length];
  return acc + x * ny - nx * y;
}, 0);

// Where each piece sits in each figure: position (centred on the figure), turn and flip.
const POSES = FIGURES.map(({ pieces }) => {
  const all = pieces.flat();
  const mx = (Math.min(...all.map((p) => p[0])) + Math.max(...all.map((p) => p[0]))) / 2;
  const my = (Math.min(...all.map((p) => p[1])) + Math.max(...all.map((p) => p[1]))) / 2;
  return pieces.map((poly, i) => {
    const base = BASE[i];
    const [cx, cy] = centroid(poly);
    const flip = Math.sign(area(poly)) !== Math.sign(area(base));
    const ax = poly[1][0] - poly[0][0];
    const ay = poly[1][1] - poly[0][1];
    return {
      x: (cx - mx) * UNIT,
      y: (cy - my) * UNIT,
      rot: (Math.atan2(ay, ax) * 180) / Math.PI,
      flip,
    };
  });
});

const SHAPES = BASE.map((poly) => {
  const [cx, cy] = centroid(poly);
  return poly.map(([x, y]) => `${((x - cx) * UNIT).toFixed(2)},${((y - cy) * UNIT).toFixed(2)}`).join(' ');
});
const TONES = ['--kit-100', '--kit-500', '--kit-300', '--kit-500', '--kit-200', '--kit-400', '--kit-300'];

const css = `
  .btn-tangram { flex: none; --k: 1; display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 4px 10px 6px; border: 0; border-radius: 14px; background: none; color: light-dark(#373a40, #f1f2f3); font: inherit; cursor: pointer; }
  .btn-tangram:hover, .btn-tangram:focus-visible { --k: 1.07; }
  .btn-tangram:active { --k: 0.96; }
  .btn-tangram:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
  .btn-tangram svg { display: block; width: 240px; height: 186px; overflow: visible; }
  .btn-tangram__piece { stroke: light-dark(#fbfcfd, #121417); stroke-width: 1.4; stroke-linejoin: round; transform: translate(calc(var(--x) * var(--k)), calc(var(--y) * var(--k))) rotate(var(--rot)) scale(1, var(--flip)); transition: transform 0.72s cubic-bezier(0.3, 1.3, 0.5, 1) calc(var(--i) * 38ms); }
  .btn-tangram__label { display: flex; align-items: baseline; gap: 7px; font-size: 14px; font-weight: 700; letter-spacing: -0.01em; }
  .btn-tangram__name { color: light-dark(#5f626a, #a7abb2); font-size: 12px; font-weight: 500; }
  @media (prefers-reduced-motion: reduce) {
    .btn-tangram__piece { transition: none; }
  }
`;

export function TangramButton() {
  const [figure, setFigure] = useState(0);
  // Turns accumulate, so a piece always takes the short way to its next angle.
  const turns = useRef(POSES[0].map((p) => p.rot));
  const poses = POSES[figure];
  poses.forEach((p, i) => {
    const delta = ((p.rot - turns.current[i] + 540) % 360) - 180;
    turns.current[i] += delta;
  });
  return (
    <>
      <style>
        {css}
      </style>
      <button type="button" className="btn-tangram" aria-label={`Shuffle the shape. Now showing: ${FIGURES[figure].name}`} onClick={() => setFigure((f) => (f + 1) % FIGURES.length)}>
        <svg viewBox="-120 -93 240 186" aria-hidden="true">
          {SHAPES.map((points, i) => (
            <polygon
              key={i}
              className="btn-tangram__piece"
              points={points}
              fill={`var(${TONES[i]})`}
              style={{ '--i': i, '--x': `${poses[i].x.toFixed(2)}px`, '--y': `${poses[i].y.toFixed(2)}px`, '--rot': `${turns.current[i].toFixed(2)}deg`, '--flip': poses[i].flip ? -1 : 1 } as CSSProperties}
            />
          ))}
        </svg>
        <span className="btn-tangram__label">
          Shuffle <span className="btn-tangram__name">{FIGURES[figure].name}</span>
        </span>
      </button>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/button-tangram
Source on GitHub

About this component

Seven solid pieces that slide and turn into a new picture on every press: a square, a sailboat, a cat, a fish. The button changes its whole silhouette.

Dependencies
None
Difficulty
Advanced
Triggers
click, morph, hover, delay stagger
Techniques
css transition, css variables, transition delay stagger, svg
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A real button whose aria-label names the shape on show; the pieces are aria-hidden. Under prefers-reduced-motion the pieces jump to the new shape.

More buttons

See all
Receipt
Stitch
Bell
Breathe