Broadsheet

The Daily Motion

Easing is back

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

const css = `
  .card-broadsheet { flex: none; --paper: light-dark(#fbfcfd, #e5e6e9); --print: light-dark(#373a40, #121417); position: relative; width: 214px; height: 222px; color: var(--print); font-family: Georgia, 'Times New Roman', serif; perspective: 700px; perspective-origin: 50% 40%; }
  .card-broadsheet__sheet { position: absolute; inset: 0; transform: translateY(45px) rotate(-1.5deg); transform-style: preserve-3d; transition: transform 0.7s cubic-bezier(0.3, 1.1, 0.5, 1); }
  .card-broadsheet[data-open='true'] .card-broadsheet__sheet { transform: none; }
  .card-broadsheet[data-spin='true'] .card-broadsheet__sheet { animation: card-broadsheet-spin 1.15s cubic-bezier(0.22, 0.7, 0.3, 1) both; }
  @keyframes card-broadsheet-spin {
    0% { transform: translateY(45px) rotate(-1090deg) scale(0.04); }
    82% { transform: translateY(45px) rotate(-1.5deg) scale(1.08); }
    90% { transform: translateY(45px) rotate(-1.5deg) scale(0.985); }
    100% { transform: translateY(45px) rotate(-1.5deg) scale(1); }
  }
  .card-broadsheet__top, .card-broadsheet__flap { position: absolute; left: 0; right: 0; box-sizing: border-box; padding: 7px 10px; background: var(--paper); }
  .card-broadsheet__top { top: 0; height: 132px; transform: translateZ(1px); box-shadow: 0 0 0 1px color-mix(in oklab, var(--print) 12%, transparent), 0 8px 18px -8px light-dark(rgba(0, 0, 0, 0.17), rgba(0, 0, 0, 0.55)); }
  .card-broadsheet__flap { top: 132px; height: 90px; padding-top: 7px; transform-origin: 50% 0; transform: rotateX(-178deg); transition: transform 0.7s cubic-bezier(0.3, 1.1, 0.5, 1); box-shadow: 0 0 0 1px color-mix(in oklab, var(--print) 12%, transparent), 0 8px 18px -8px light-dark(rgba(0, 0, 0, 0.17), rgba(0, 0, 0, 0.55)); pointer-events: none; }
  .card-broadsheet[data-open='true'] .card-broadsheet__flap { transform: rotateX(0); pointer-events: auto; }
  .card-broadsheet__crease { position: absolute; left: 0; right: 0; top: 0; height: 16px; background: linear-gradient(to bottom, color-mix(in oklab, var(--print) 22%, transparent), transparent); pointer-events: none; }
  .card-broadsheet__name { margin: 0; font-size: 19px; font-weight: 700; letter-spacing: -0.01em; line-height: 1.05; text-align: center; }
  .card-broadsheet__dateline { display: flex; justify-content: space-between; margin: 3px 0 0; padding: 2px 0; border-top: 3px double var(--print); border-bottom: 1px solid var(--print); font-size: 7px; letter-spacing: 0.05em; text-transform: uppercase; }
  .card-broadsheet__head { margin: 5px 0 0; font-family: 'Times New Roman', Times, serif; font-size: 27px; font-weight: 700; letter-spacing: -0.03em; line-height: 0.98; text-align: center; text-transform: uppercase; text-wrap: balance; }
  .card-broadsheet__more { display: block; width: 100%; margin-top: 5px; padding: 3px 0 0; border: 0; border-top: 1px solid var(--print); background: none; color: inherit; font: italic 700 10.5px/1.3 Georgia, 'Times New Roman', serif; text-align: center; cursor: pointer; }
  .card-broadsheet__more:hover { text-decoration: underline; }
  .card-broadsheet__more:focus-visible { outline: 2px solid var(--print); outline-offset: 1px; }
  .card-broadsheet__body { display: grid; grid-template-columns: 76px 1fr 1fr; gap: 7px; }
  .card-broadsheet__photo { height: 74px; background: radial-gradient(circle at 50% 50%, var(--print) 0.9px, transparent 1.15px) 0 0 / 3px 3px, radial-gradient(circle at 70% 26%, var(--paper) 12px, transparent 13px), linear-gradient(170deg, color-mix(in oklab, var(--print) 26%, var(--paper)), color-mix(in oklab, var(--print) 58%, var(--paper))); }
  .card-broadsheet__col { margin: 0; font-size: 6.2px; line-height: 1.22; text-align: justify; hyphens: auto; }
  .card-broadsheet__col::first-letter { font-size: 15px; float: left; line-height: 0.86; padding-right: 2px; font-weight: 700; }
  .card-broadsheet__col + .card-broadsheet__col::first-letter { font-size: inherit; float: none; padding: 0; font-weight: 400; }
  @media (prefers-reduced-motion: reduce) {
    .card-broadsheet[data-spin='true'] .card-broadsheet__sheet { animation: none; }
    .card-broadsheet__sheet, .card-broadsheet__flap { transition: none; }
  }
`;

export function BroadsheetCard() {
  const root = useRef<HTMLElement>(null);
  const [open, setOpen] = useState(false);
  const [spin, setSpin] = useState(false);

  // It only spins in once it is on screen, or nobody would see it land.
  useEffect(() => {
    const el = root.current;
    if (!el) return;
    const io = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) {
        setSpin(true);
        io.disconnect();
      }
    });
    io.observe(el);
    return () => io.disconnect();
  }, []);

  return (
    <>
      <style>
        {css}
      </style>
      <article ref={root} className="card-broadsheet" data-open={open} data-spin={spin && !open} onAnimationEnd={() => setSpin(false)}>
        <div className="card-broadsheet__sheet">
          <div className="card-broadsheet__top">
            <p className="card-broadsheet__name">The Daily Motion</p>
            <p className="card-broadsheet__dateline">
              <span>Vol. XII</span>
              <span>Late edition</span>
              <span>2 cents</span>
            </p>
            <h3 className="card-broadsheet__head">Easing is back</h3>
            <button type="button" className="card-broadsheet__more" aria-expanded={open} onClick={() => setOpen((o) => !o)}>
              {open ? 'Fold it back up' : 'Continued below the fold'}
            </button>
          </div>
          <div className="card-broadsheet__flap" aria-hidden={!open}>
            <span className="card-broadsheet__crease" />
            <div className="card-broadsheet__body">
              <div className="card-broadsheet__photo" />
              <p className="card-broadsheet__col">Linear motion, long the choice of the hurried, was seen leaving town on Tuesday. Witnesses describe a slow start and a gentle landing.</p>
              <p className="card-broadsheet__col">Designers across the city report that objects now arrive as though they had weight. Asked for comment, the curve would only say that timing is everything.</p>
            </div>
          </div>
        </div>
      </article>
    </>
  );
}

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

Install with shadcn

npx shadcn@latest add @movin/card-broadsheet
Source on GitHub

About this component

An article as a folded newspaper. It spins in like the old film trope and lands; Continued below the fold really does swing the bottom half down, with dense serif type all the way.

Dependencies
None
Difficulty
Intermediate
Triggers
card, click, 3d, state change
Techniques
3d transform, perspective, keyframes, css transition, radial gradient
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

An article with a heading and one button that keeps focus as it toggles, with aria-expanded telling whether the fold is open. The fold is aria-hidden while closed. Under prefers-reduced-motion it does not spin and opens at once.

More cards

See all
Clapper

Pick your hand

Hand of cards

Your profile6 of 9

Next: Add a photo

Jigsaw
PSRW
API keysFour on the ring. Pick one.
Keyring