모션·인터랙션 / Cadence
Split-Flap Headlines
텍스트 · 로딩 모션. 미리보기와 원본 코드·프롬프트를 이곳에서 확인하세요.
미리보기 안에서 스크롤하거나 마우스를 움직여 모션을 확인하세요.
HTML · CSS · JavaScript 전체 코드
<!doctype html>
<!--
Split-Flap Headlines
Lines flip into place letter by letter like a departure board. Every tile has two
static halves (top shows the next character, bottom the current) and two flaps: a flip
folds the top flap down (showing the current character), then lands the bottom flap
(showing the next). Each tile cycles through a few random characters before it lands,
columns and rows staggered. Several boards play in a loop; click to flip to the next.
Dependencies: none (CSS 3D transforms + the Web Animations API).
Tweak (CONFIG in the script):
- boards: the lines of each board (upper-cased; the board is as wide as the longest line)
- charset: the random characters a tile cycles through
- flipDuration: 120ms per flip (top half ease-in, bottom half ease-out)
- minCycles / maxCycles: 2–6 random characters before landing
- columnStagger: 55ms between columns; rowStagger: 320ms between rows
- startWhenVisible: 0.4 (fraction of the board visible before it starts)
- HOLD_MS: 2500ms a finished board holds before the next flips in
- CSS: --tile-bg / --tile-ink (black tiles, white letters), --tile-size
From The Animations Index, 08 Split-Flap Headlines (own project).
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Split-Flap Headlines</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
min-height: 100svh;
display: grid;
place-items: center;
padding: 0 clamp(16px, 2.5vw, 32px);
background: #fff;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
cursor: pointer; /* a click flips to the next board */
}
.board {
--tile-bg: #000;
--tile-ink: #fff;
--page-bg: #fff; /* the thin gap between the halves */
--gutter: clamp(16px, 2.5vw, 32px);
--cols: 17; /* set by the script to the longest line */
--tile-size: clamp(1.6rem, 5.2vw, 4.5rem);
display: flex;
flex-direction: column;
gap: 0.35em;
/* shrinks so the longest row always fits the screen; 0.86em = tile + gap */
font-size: min(var(--tile-size), calc((100vw - 2 * var(--gutter)) / (var(--cols) * 0.86)));
}
.row { display: flex; gap: 0.08em; }
.tile {
position: relative;
width: 0.78em;
height: 1.25em;
perspective: 6em;
font-weight: 500;
color: var(--tile-ink);
line-height: 1.25em;
text-align: center;
flex: none;
}
/* Both halves and both flaps share the same box; the inner span is the full tile
height, clipped to the top or bottom half by its parent. */
.tile__half,
.tile__flap {
position: absolute;
left: 0;
width: 100%;
height: 50%;
overflow: hidden;
background: var(--tile-bg);
backface-visibility: hidden;
}
.tile__half span,
.tile__flap span { position: absolute; left: 0; width: 100%; height: 1.25em; }
.tile__half--top, .tile__flap--top { top: 0; border-radius: 0.08em 0.08em 0 0; }
.tile__half--bottom, .tile__flap--bottom { bottom: 0; border-radius: 0 0 0.08em 0.08em; }
.tile__half--top span, .tile__flap--top span { top: 0; }
.tile__half--bottom span, .tile__flap--bottom span { bottom: 0; }
.tile__flap--top { transform-origin: 50% 100%; z-index: 2; }
.tile__flap--bottom { transform-origin: 50% 0%; transform: rotateX(90deg); z-index: 2; }
/* The thin gap between the halves */
.tile::after {
content: "";
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 1px;
margin-top: -0.5px;
background: var(--page-bg);
z-index: 3;
}
</style>
</head>
<body>
<div class="board" id="board" role="list" aria-live="polite"></div>
<script>
const CONFIG = {
boards: [
["Departures", "Motion, collected", "Since 2026"],
["Olive Grove 01", "Linen 02", "Pier in Winter 03"],
["Sea Wall 04", "Lavender 05", "Chalk Coast 06"],
],
charset: "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789:",
flipDuration: 120, // ms for one full flip (top down, then bottom in)
minCycles: 2, // random characters before landing
maxCycles: 6,
columnStagger: 55, // ms between neighbouring columns
rowStagger: 320, // ms between rows
startWhenVisible: 0.4, // fraction of the board visible before it starts
};
const HOLD_MS = 2500; // how long a finished board holds before the next one flips in
/* Build the board */
const board = document.getElementById("board");
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const boards = CONFIG.boards.map((lines) => lines.map((line) => line.toUpperCase()));
const rowCount = Math.max(...boards.map((lines) => lines.length));
const columns = Math.max(...boards.flat().map((line) => line.length));
board.style.setProperty("--cols", columns);
function makeTile() {
const tile = document.createElement("span");
tile.className = "tile";
tile.innerHTML = `
<span class="tile__half tile__half--top"><span></span></span>
<span class="tile__half tile__half--bottom"><span></span></span>
<span class="tile__flap tile__flap--top"><span></span></span>
<span class="tile__flap tile__flap--bottom"><span></span></span>
`;
return {
el: tile,
top: tile.children[0].firstElementChild,
bottom: tile.children[1].firstElementChild,
flapTop: tile.children[2],
flapBottom: tile.children[3],
current: " ",
};
}
const rows = [];
for (let r = 0; r < rowCount; r++) {
const row = document.createElement("div");
row.className = "row";
row.setAttribute("role", "listitem");
const tiles = [];
for (let i = 0; i < columns; i++) {
const tile = makeTile();
tile.el.setAttribute("aria-hidden", "true");
row.appendChild(tile.el);
tiles.push(tile);
}
board.appendChild(row);
rows.push({ el: row, tiles });
}
/* Text of every row for one board, padded to the full width */
function boardText(index) {
const lines = boards[index];
return rows.map((_, r) => (lines[r] || "").padEnd(columns, " "));
}
function label(index) {
rows.forEach((row, r) => row.el.setAttribute("aria-label", boards[index][r] || ""));
}
/* Shows a character on a tile with no animation */
function setTile(tile, char) {
tile.current = char;
tile.top.textContent = char;
tile.bottom.textContent = char;
tile.flapTop.firstElementChild.textContent = char;
tile.flapBottom.firstElementChild.textContent = char;
}
/* Cancellable waits, so a click can restart the board mid-run */
let run = 0; // increases on every play; older runs stop at their next step
const pending = new Set();
function wait(ms) {
return new Promise((resolve) => {
const entry = { resolve, timer: setTimeout(() => { pending.delete(entry); resolve(); }, ms) };
pending.add(entry);
});
}
function cancelWaits() {
pending.forEach((entry) => { clearTimeout(entry.timer); entry.resolve(); });
pending.clear();
}
/* One flip: current -> next */
async function flip(tile, next) {
const half = CONFIG.flipDuration / 2;
const current = tile.current;
// Static halves: top already shows the next char, bottom still the current.
tile.top.textContent = next;
tile.bottom.textContent = current;
tile.flapTop.firstElementChild.textContent = current;
tile.flapBottom.firstElementChild.textContent = next;
// Top flap folds down over the bottom half...
tile.flapBottom.style.transform = "rotateX(90deg)";
await tile.flapTop.animate([{ transform: "rotateX(0deg)" }, { transform: "rotateX(-90deg)" }], {
duration: half,
easing: "ease-in",
fill: "forwards",
}).finished;
// ...then the bottom flap lands with the next character.
await tile.flapBottom.animate([{ transform: "rotateX(90deg)" }, { transform: "rotateX(0deg)" }], {
duration: half,
easing: "ease-out",
fill: "forwards",
}).finished;
setTile(tile, next);
tile.flapTop.getAnimations().forEach((a) => a.cancel());
tile.flapBottom.getAnimations().forEach((a) => a.cancel());
tile.flapBottom.style.transform = "rotateX(90deg)";
}
/* Cycles a tile through a few random characters, then lands on `target` */
async function flipTo(tile, target, delay, id) {
await wait(delay);
if (id !== run) return;
if (target === tile.current && target === " ") return;
const cycles = CONFIG.minCycles + Math.floor(Math.random() * (CONFIG.maxCycles - CONFIG.minCycles + 1));
for (let i = 0; i < cycles; i++) {
const random = CONFIG.charset[Math.floor(Math.random() * CONFIG.charset.length)];
await flip(tile, random);
if (id !== run) return;
}
await flip(tile, target);
}
/* Playing the boards in a loop */
let index = 0;
let active = Promise.resolve();
async function play(boardIndex) {
const id = ++run;
cancelWaits();
await active; // let flips that are mid-air land before starting again
if (id !== run) return;
index = boardIndex;
label(index);
const text = boardText(index);
const runs = [];
rows.forEach((row, r) => {
row.tiles.forEach((tile, c) => {
runs.push(flipTo(tile, text[r][c], r * CONFIG.rowStagger + c * CONFIG.columnStagger, id));
});
});
active = Promise.all(runs);
await active;
if (id !== run) return;
await wait(HOLD_MS);
if (id === run) play((index + 1) % boards.length);
}
function showBoard(boardIndex) {
index = boardIndex;
label(index);
const text = boardText(index);
rows.forEach((row, r) => row.tiles.forEach((tile, c) => setTile(tile, text[r][c])));
}
// Reduced motion: the first board, shown without flipping (a click swaps boards).
if (reduced) {
showBoard(0);
} else {
rows.forEach((row) => row.tiles.forEach((tile) => setTile(tile, " ")));
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
play(0);
observer.disconnect();
}
},
{ threshold: CONFIG.startWhenVisible }
);
observer.observe(board);
}
// A click flips straight to the next board.
document.body.addEventListener("click", () => {
const next = (index + 1) % boards.length;
if (reduced) showBoard(next);
else play(next);
});
</script>
<script type="module" src="https://static.cloudflareinsights.com/beacon.min.js/vce701823e78c4301a35b7e07294d87301792088814092" integrity="sha512-UKNk8GbxkABxKcXYuLkO16EleLGUkcvJWtpSNmExE0Too3ZaFAxTscNWEc1OdAv5/BikHawapSCaiD2XCmp+Bw==" data-cf-beacon='{"version":"2024.11.0","token":"c54fa41b83884ade97f239ecb41aee46","r":1,"spa":2}' crossorigin="anonymous"></script>
</body>
</html>
원본 구현 프롬프트
# Split-Flap Headlines: lines that flip into place letter by letter like a departure board
**Goal:** build a split-flap ("Solari") board where each headline flips in tile by tile, every tile cycling through a few random characters before it lands, and several boards play in a loop.
## Visual behaviour
1. **The board.** A grid of tiles: one row per line, and as many columns as the longest line in any board (shorter lines are padded with blank tiles). Black tiles, white upper-case letters, a thin 1 px line of page colour across the middle of each tile (the split). Tiles start blank.
2. **Start.** When 40% of the board is visible, the first board plays.
3. **Stagger.** Each tile starts after `row × 320 ms + column × 55 ms`, so the text ripples left to right and each row follows the one above.
4. **Cycling.** Each tile flips through a random number of random characters (between 2 and 6, from `A–Z 0–9 :`), then flips once more to its target character. Tiles whose current and target are both blank do nothing.
5. **One flip (120 ms total), current → next:**
- Set up: the static top half already shows **next**, the static bottom half still shows **current**; the top flap shows **current**, the bottom flap shows **next** and is folded up (`rotateX(90deg)`, out of sight).
- First 60 ms (`ease-in`): the top flap rotates from `rotateX(0)` to `rotateX(-90deg)` around its bottom edge, folding down and revealing the next character's top half behind it.
- Next 60 ms (`ease-out`): the bottom flap rotates from `rotateX(90deg)` to `rotateX(0)` around its top edge, landing over the bottom half with the next character.
- Then set all four parts to **next** and reset both flaps (no visible jump).
6. **Loop.** When every tile of a board has landed, hold 2.5 s, then the next board flips in over it (tiles flip from their current letters, no blanking). After the last board it goes back to the first.
7. **Click** anywhere: flip to the next board straight away. Let flips that are mid-air finish first, then start the new board (no tile should ever jump).
## Exact values
| What | Value |
|---|---|
| Flip | 120 ms: 60 ms `ease-in` (top flap 0 → −90°) + 60 ms `ease-out` (bottom flap 90° → 0) |
| Random cycles | 2–6 per tile, then the target |
| Column stagger | 55 ms |
| Row stagger | 320 ms |
| Hold | 2.5 s per board |
| Start | 40% of the board visible |
| Tile | 0.78em × 1.25em, 0.08em gap, rows 0.35em apart, corner radius 0.08em, perspective 6em |
| Tile size | `font-size: min(clamp(1.6rem, 5.2vw, 4.5rem), (100vw − 2 × gutter) / (columns × 0.86))` |
| Colours | tiles `#000`, letters `#fff`, weight 500, split line = page background |
## Markup and CSS
Each tile is built in JS:
```html
<span class="tile">
<span class="half half--top"><span>A</span></span>
<span class="half half--bottom"><span>A</span></span>
<span class="flap flap--top"><span>A</span></span>
<span class="flap flap--bottom"><span>A</span></span>
</span>
```
- `.tile`: `position: relative; width: 0.78em; height: 1.25em; line-height: 1.25em; text-align: center; perspective: 6em`.
- Halves and flaps: `position: absolute; left: 0; width: 100%; height: 50%; overflow: hidden; background: #000; backface-visibility: hidden`. Top ones at `top: 0`, bottom ones at `bottom: 0`.
- The inner `<span>` is the full tile height (1.25em), anchored to the top (in top parts) or bottom (in bottom parts), so each part shows exactly half the glyph.
- `.flap--top { transform-origin: 50% 100%; z-index: 2 }`, `.flap--bottom { transform-origin: 50% 0%; transform: rotateX(90deg); z-index: 2 }`.
- `.tile::after`: a 1 px line in the page colour across the middle (`top: 50%`, `z-index: 3`).
- Rows are flex rows (`gap: 0.08em`); the board is a flex column (`gap: 0.35em`), centred on the page.
## Libraries
None. Use the Web Animations API (`el.animate(keyframes, { duration: 60, easing, fill: "forwards" }).finished`) and `async`/`await` to chain the two halves of each flip and the cycles of each tile. Keep a run counter and cancellable `setTimeout` waits, so a click can stop an older run at its next step. (CSS transitions with `transitionend` work too.)
## Responsive and reduced motion
- Tiles shrink with the viewport so the longest row always fits the screen width (the `min()` above).
- `prefers-reduced-motion: reduce`: show the first board's text immediately, with no flipping; a click swaps boards instantly.
- Add `aria-label` with each row's real text and `aria-hidden` on the tiles, so screen readers read the words, not the random letters.
## Acceptance criteria
- Tiles ripple in left to right, each row 320 ms after the previous one.
- Every tile visibly cycles through a few random characters with a crisp two-stage fold (top half down, bottom half up) before landing.
- No tile ever shows a flicker or jump between flips; the split line stays visible.
- Each board holds 2.5 s, then the next flips in over it; the boards loop.
- Clicking jumps to the next board without breaking tiles that were mid-flip.
- The longest row fits the screen at any width; reduced motion shows static text.
보관·활용 메모
Cadence에서 제공하는 코드와 프롬프트, 미리보기 영상을 이 사이트에 보관했습니다. 코드는 다운로드한 원본으로 제공하며 실행 화면은 별도로 격리되어 있습니다. 코드의 외부 라이브러리·샘플 이미지 요청은 유지됩니다.
보관일: 2026-10-26 · 프로젝트 적용: 별도 검증 필요
원본 코드·프롬프트·제작자 정보를 함께 보관했습니다. 실제 납품 시 코드와 포함 소재의 이용 조건을 확인하세요.
코드 SHA-256: 44a1c33b1d5353a84158a47065fa76ba0ade90c6d5f301c38aaa76b67c290d69