Block
Changelog feed
A monthly changelog feed that separates new features, improvements, and fixes.
- #arc
- #block
- #changelog
- #release
- #feed
- #updates
- #timeline
1280px
Install
One command installs the files and packages this item needs.
TerminalTerminalTerminalbash
npx shadcn@latest add https://dsui.vercel.app/r/changelog-feed.jsonFiles
components/arc/blocks/changelog-feed/changelog-feed.module.csscomponents/arc/blocks/changelog-feed/changelog-feed.tsx
npm packages
motion@^14.2.0
Registry items it pulls in
Source
View as Markdownchangelog-feed.module.csschangelog-feed.module.csschangelog-feed.module.csscss
.feed { box-sizing: border-box; width: min(100%, 920px); margin-inline: auto; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--surface); color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); }
.feed *, .feed *::before, .feed *::after { box-sizing: border-box; }
.srOnly { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
/* Header */
.header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px 24px; padding: 32px 28px 20px; }
.heading { min-width: 0; }
.heading h2 { margin: 0; font: 400 var(--text-3xl)/var(--leading-display) var(--font-display); letter-spacing: var(--tracking-display); }
.heading p { margin: 8px 0 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }
/* Subscribe */
.subscribeWrap { position: relative; flex: none; padding-top: 2px; }
.subscribe { height: 38px; overflow: hidden; border: 1px solid var(--accent); border-radius: var(--radius-pill); background: var(--accent); color: var(--accent-foreground); transition: background-color var(--duration-standard) var(--ease-standard), border-color var(--duration-standard) var(--ease-standard), color var(--duration-standard) var(--ease-standard); }
.subscribe[data-state="editing"] { border-color: var(--border-strong); background: var(--surface); color: var(--foreground); }
.subscribe[data-state="done"] { border-color: var(--border); background: var(--surface-muted); color: var(--foreground); }
.subscribe[data-error] { border-color: var(--danger); }
.subscribeInner { position: relative; display: flex; width: max-content; height: 100%; }
.subscribeIdle { display: inline-flex; align-items: center; gap: 7px; height: 100%; padding: 0 17px 0 15px; border: 0; background: transparent; color: inherit; font: 500 var(--text-sm)/1 var(--font-body); white-space: nowrap; cursor: pointer; outline: none; }
.subscribeForm { display: flex; align-items: center; gap: 6px; height: 100%; padding: 0 4px 0 15px; }
.subscribeForm input { width: 188px; min-width: 0; height: 100%; padding: 0; border: 0; background: transparent; color: var(--foreground); font: var(--text-sm)/1 var(--font-body); outline: none; }
.subscribeForm input::placeholder { color: var(--text-muted); }
.subscribeSubmit { display: grid; flex: none; place-items: center; width: 28px; height: 28px; padding: 0; border: 0; border-radius: 50%; background: var(--accent); color: var(--accent-foreground); cursor: pointer; outline: none; transition: transform var(--duration-instant) var(--ease-standard); }
.subscribeSubmit:active { transform: scale(.94); }
.subscribeDone { display: inline-flex; align-items: center; gap: 7px; height: 100%; padding: 0 5px 0 13px; font-size: var(--text-sm); font-weight: 500; white-space: nowrap; }
.drawnCheck { flex: none; color: var(--success); }
.subscribeUndo { height: 26px; margin-left: 4px; padding: 0 10px; border: 0; border-radius: var(--radius-pill); background: transparent; color: var(--text-secondary); font: 500 var(--text-xs)/1 var(--font-body); cursor: pointer; outline: none; transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); }
.subscribeNote { position: absolute; top: calc(100% + 7px); right: 6px; margin: 0; color: var(--text-muted); font-size: var(--text-xs); line-height: 16px; white-space: nowrap; }
.subscribeNote[data-error] { color: var(--danger); }
.subscribeNote span { display: inline-block; }
/* Filters */
.toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 16px; padding: 6px 28px 20px; }
.chips { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.chip { display: inline-flex; align-items: center; gap: 8px; height: 34px; padding: 0 13px 0 12px; border: 1px solid var(--border); border-radius: var(--radius-pill); background: transparent; color: var(--text-secondary); font: 500 var(--text-sm)/1 var(--font-body); cursor: pointer; transition: background-color var(--duration-standard) var(--ease-standard), border-color var(--duration-standard) var(--ease-standard), color var(--duration-standard) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); }
.chip:active { transform: scale(.97); }
.chip[aria-pressed="true"] { border-color: var(--foreground); background: var(--foreground); color: var(--background); }
.chipMark { display: grid; flex: none; place-items: center; overflow: hidden; border-radius: 50%; background: var(--kind); transition: background-color var(--duration-standard) var(--ease-standard); }
.chip[aria-pressed="true"] .chipMark { background: var(--mark); }
.chipTick { display: grid; place-items: center; color: var(--tick); }
.chipCount { color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; transition: color var(--duration-standard) var(--ease-standard); }
.chip[aria-pressed="true"] .chipCount { color: color-mix(in srgb, var(--background) 62%, transparent); }
.clear { height: 34px; padding: 0 8px; border: 0; border-radius: var(--radius-pill); background: transparent; color: var(--text-secondary); font: 500 var(--text-sm)/1 var(--font-body); cursor: pointer; }
.shown { display: inline-flex; align-items: baseline; gap: 0; margin: 0; color: var(--text-muted); font-size: var(--text-sm); font-variant-numeric: tabular-nums; white-space: nowrap; }
.shown > span:last-child { display: inline-block; }
.shownCount { display: inline-flex; }
.chip[data-kind="new"], .kind[data-kind="new"] { --kind: var(--accent); --mark: var(--accent); --tick: var(--accent-foreground); }
.chip[data-kind="improved"], .kind[data-kind="improved"] { --kind: var(--text-muted); --mark: var(--background); --tick: var(--foreground); }
.chip[data-kind="fixed"], .kind[data-kind="fixed"] { --kind: var(--success); --mark: var(--success); --tick: var(--background); }
/* Rolling numbers */
.roll { position: relative; display: inline-flex; overflow: hidden; font-variant-numeric: tabular-nums; }
.roll > span { display: inline-block; }
/* Month bar */
.monthBar { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 60px; padding: 10px 28px; border-block: 1px solid var(--border); }
.monthLabel { display: flex; align-items: baseline; gap: 12px; min-width: 0; }
.monthText { position: relative; display: inline-flex; font: 500 var(--text-lg)/var(--leading-body) var(--font-body); letter-spacing: var(--tracking-body); white-space: nowrap; }
.monthText > span { display: inline-block; }
.monthYear { display: inline; }
.monthCount { display: inline-flex; align-items: baseline; gap: 0; color: var(--text-muted); font-size: var(--text-sm); font-variant-numeric: tabular-nums; white-space: nowrap; }
.monthNav { display: flex; flex: none; gap: 2px; padding: 3px; border-radius: var(--radius-pill); background: var(--surface-muted); }
.monthNav button { position: relative; min-width: 42px; height: 28px; padding: 0 10px; border: 0; border-radius: var(--radius-pill); background: transparent; color: var(--text-secondary); font: 500 var(--text-xs)/1 var(--font-body); cursor: pointer; transition: color var(--duration-standard) var(--ease-standard); }
.monthNav button[aria-current="true"] { color: var(--foreground); }
.monthPill { position: absolute; inset: 0; border-radius: var(--radius-pill); background: var(--surface); box-shadow: 0 0 0 1px var(--border), var(--shadow-resting); }
.monthShort { position: relative; z-index: 1; }
/* Feed */
.scroller { position: relative; height: 560px; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; outline: none; -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 36px), transparent); mask-image: linear-gradient(to bottom, #000 calc(100% - 36px), transparent); }
.list { position: relative; }
.group { position: relative; }
.groupTitle { margin: 0; padding: 30px 28px 10px; border-bottom: 1px solid var(--border-subtle); color: var(--text-muted); font-size: var(--text-sm); font-weight: 500; }
.entries { position: relative; margin: 0; padding: 0; list-style: none; }
.entry { position: relative; border-bottom: 1px solid var(--border-subtle); background: var(--surface); }
.entries > .entry:last-child { border-bottom: 0; }
.row { display: grid; grid-template-columns: 104px minmax(0, 1fr) 20px; align-items: start; gap: 4px 24px; width: 100%; padding: 22px 28px; border: 0; background: transparent; color: inherit; font: inherit; letter-spacing: inherit; text-align: left; cursor: pointer; transition: background-color var(--duration-standard) var(--ease-standard); }
.meta { display: grid; justify-items: start; gap: 8px; padding-top: 1px; }
.meta time { color: var(--text-secondary); font-size: var(--text-sm); line-height: 20px; font-variant-numeric: tabular-nums; }
.version { padding: 1px 7px; border: 1px solid var(--border); border-radius: 7px; color: var(--text-secondary); font: 500 var(--text-xs)/18px ui-monospace, SFMono-Regular, Menlo, monospace; font-variant-numeric: tabular-nums; letter-spacing: 0; white-space: nowrap; }
.body { display: grid; gap: 6px; min-width: 0; }
.kind { display: inline-flex; align-items: center; gap: 7px; color: var(--text-secondary); font-size: var(--text-xs); font-weight: 500; line-height: 20px; }
.kind i { width: 7px; height: 7px; border-radius: 50%; background: var(--kind); }
.title { color: var(--foreground); font-size: var(--text-base); font-weight: 500; line-height: var(--leading-body); }
.summary { max-width: 62ch; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }
.chevron { display: grid; place-items: center; width: 20px; height: 20px; margin-top: 1px; color: var(--text-muted); transition: color var(--duration-standard) var(--ease-standard); }
.entry[data-open] .chevron { color: var(--foreground); }
.panel { overflow: hidden; }
.panelInner { padding: 0 72px 28px 156px; }
.details { display: grid; gap: 6px; max-width: 62ch; margin: 0; padding: 0; list-style: none; }
.details li { position: relative; padding-left: 16px; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }
.details li::before { position: absolute; top: .75em; left: 1px; width: 6px; height: 1px; content: ""; background: var(--text-muted); }
.photo { max-width: 560px; margin: 18px 0 0; }
.photoFrame { position: relative; aspect-ratio: 16 / 10; overflow: hidden; border-radius: 16px; background: var(--surface-muted); }
.photoFrame img { display: block; width: 100%; height: 100%; object-fit: cover; }
.photoFrame::after { position: absolute; inset: 0; border-radius: inherit; content: ""; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--foreground) 8%, transparent); pointer-events: none; }
.photo figcaption { margin-top: 8px; color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); }
.code { max-width: 560px; margin-top: 18px; overflow: hidden; border: 1px solid var(--border); border-radius: 16px; background: var(--surface-muted); }
.codeHead { display: flex; align-items: center; justify-content: space-between; gap: 12px; height: 40px; padding: 0 6px 0 14px; border-bottom: 1px solid var(--border); color: var(--text-muted); font-size: var(--text-xs); }
.code pre { margin: 0; padding: 14px 16px 16px; overflow-x: auto; color: var(--foreground); font: var(--text-xs)/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 0; tab-size: 2; scrollbar-width: thin; }
.endCap { margin: 0; padding: 32px 28px 56px; color: var(--text-muted); font-size: var(--text-sm); text-align: center; }
@media (hover: hover) and (pointer: fine) {
.chip:not([aria-pressed="true"]):hover { border-color: var(--border-strong); color: var(--foreground); }
.clear:hover, .monthNav button:hover, .subscribeUndo:hover { color: var(--foreground); }
.subscribeUndo:hover { background: var(--surface); }
.row:hover { background: color-mix(in srgb, var(--surface-muted) 60%, transparent); }
.row:hover .chevron { color: var(--foreground); }
}
@media (max-width: 640px) {
.feed { border-radius: var(--radius-panel); }
.header { flex-direction: column; gap: 18px; padding: 26px 20px 26px; }
.subscribeNote { right: auto; left: 6px; }
.subscribeForm input { width: 172px; font-size: var(--text-base); }
.toolbar { padding: 4px 20px 18px; }
.monthBar { padding-inline: 20px; }
.scroller { height: 520px; }
.groupTitle { padding-inline: 20px; }
.row { grid-template-columns: minmax(0, 1fr) 20px; grid-template-areas: "meta chevron" "body body"; gap: 10px 12px; padding: 18px 20px; }
.meta { display: flex; grid-area: meta; align-items: center; gap: 10px; padding-top: 0; }
.body { grid-area: body; }
.chevron { grid-area: chevron; margin-top: 0; }
.panelInner { padding: 0 20px 24px; }
}
@media (max-width: 480px) {
.monthCount, .monthYear { display: none; }
.monthText { font-size: var(--text-base); }
.monthNav button { min-width: 0; padding: 0 9px; }
.shown { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.feed *, .feed *::before, .feed *::after { transition-duration: 0ms !important; }
.chip:active, .subscribeSubmit:active { transform: none; }
}
.heading p:first-child { margin-top: 0; }
changelog-feed.tsxchangelog-feed.tsxchangelog-feed.tsxtsx
"use client";
import { useCallback, useEffect, useId, useMemo, useRef, useState, type FocusEvent, type FormEvent, type KeyboardEvent, type ReactNode } from "react";
import Image, { type StaticImageData } from "next/image";
import { AnimatePresence, motion, useAnimate, type Transition, type Variants } from "motion/react";
import { useReducedMotion } from "../../lib/use-reduced-motion";
import { CopyButton } from "../../copy-button/copy-button";
import { motionTokens } from "../../lib/motion-tokens";
import { photo } from "../../lib/media";
import styles from "./changelog-feed.module.css";
import { UiIcon } from "../../theme/ui-icon";
import { playUiSound } from "../../sound/play";
export type ChangelogKind = "new" | "improved" | "fixed";
type Kind = ChangelogKind;
export type ChangelogMedia =
| { type: "photo"; src: StaticImageData | string; alt: string; caption: string; /** Pixel size, needed when `src` is a URL rather than an imported image. */ width?: number; height?: number }
| { type: "code"; file: string; code: string };
type Media = ChangelogMedia;
export type ChangelogEntry = { id: string; month: string; date: string; iso: string; version: string; kind: Kind; title: string; summary: ReactNode; details: ReactNode[]; media?: Media };
export type ChangelogMonth = { key: string; label: string; short: string };
type Entry = ChangelogEntry;
type Month = ChangelogMonth;
const kindIds: Kind[] = ["new", "improved", "fixed"];
/** Every word the feed says itself. Pass `labels` to translate or reword it; anything left out stays Korean. */
export interface ChangelogLabels {
kinds: Record<Kind, string>;
/** The accessible name of the filter chips. */
filterGroup: string;
/** Clears the kind filters. */
clearFilters: string;
/** The accessible name of the bar that jumps between groups. */
groupNav: string;
/** The accessible name of the scrolling list. */
list: string;
/** Shown in the group bar when no group matches the filters. */
empty: string;
/** "3개 업데이트": the count beside a group name. The number is rendered separately, so this is the words after it. */
updatesAfterNumber: string;
/** The words after the number of visible updates: when nothing is filtered, and when some are (`total` is the unfiltered count). */
shownAll: string;
/** `{total}` is the unfiltered count. */
shownSome: string;
/** Spoken status after a filter changes: `{total}` and `{shown}` are counts. */
statusAll: string;
statusSome: string;
/** The label of a group button for screen readers: `{label}` is the group name. */
viewGroup: string;
subscribe: { idle: string; email: string; submit: string; invalid: string; done: string; doneNote: string; undo: string; undoLabel: string };
}
/** Fills `{name}` slots in a label. Labels are plain strings so they can cross from a server component. */
const fill = (template: string, values: Record<string, string | number>) => template.replace(/\{(\w+)\}/g, (slot, name: string) => String(values[name] ?? slot));
const defaultLabels: ChangelogLabels = {
kinds: { new: "새 기능", improved: "개선", fixed: "수정" },
filterGroup: "유형별로 거르기",
clearFilters: "모두 보기",
groupNav: "월별로 이동",
list: "릴리스 노트",
empty: "업데이트가 없어요",
updatesAfterNumber: "개 업데이트",
shownAll: "개 업데이트",
shownSome: "개 / 전체 {total}개",
statusAll: "업데이트 {total}개를 보여 주고 있어요",
statusSome: "업데이트 {total}개 중 {shown}개를 보여 주고 있어요",
viewGroup: "{label} 보기",
subscribe: { idle: "구독하기", email: "이메일 주소", submit: "릴리스 노트 구독하기", invalid: "올바른 이메일 주소를 입력해 주세요", done: "구독했어요", doneNote: "구독했어요.", undo: "되돌리기", undoLabel: "{email} 구독 취소하기" },
};
/** Sample photos load by URL from public/media, so the block installs without binary imports. */
const ceramics = photo("terracotta-waves"), journal = photo("alpine-lake"), bottle = photo("wine-bar"), interior = photo("living-room"), seaAtDusk = photo("sea-at-dusk");
const exampleMonths: Month[] = [
{ key: "2026-09", label: "2026년 9월", short: "9월" },
{ key: "2026-08", label: "2026년 8월", short: "8월" },
{ key: "2026-07", label: "2026년 7월", short: "7월" },
{ key: "2026-06", label: "2026년 6월", short: "6월" },
];
const webhookSnippet = `import { verifyWebhook } from "@halden/node";
const event = verifyWebhook(body, headers, secret);
if (event.type === "order.paid") {
await fulfil(event.data.order); // ord_7Hq2Lx
}`;
const dnsSnippet = `Type Name Value
CNAME shop edge.halden.example
TXT _halden verify=hd_4f81c2`;
const exampleEntries: Entry[] = [
{ id: "masonry", month: "2026-09", date: "9월 18일", iso: "2026-09-18", version: "4.12.0", kind: "new", title: "벽돌쌓기형 갤러리 레이아웃", summary: "세로 사진과 가로 사진을 섞어도 한 장도 잘리지 않아요.", details: ["화면 크기마다 열을 2개에서 5개까지 고를 수 있어요.", "이미지를 끌면 움직이는 동안 열이 다시 균형을 맞춰요.", "캡션이 없으면 이미지 대체 텍스트를 보여 줘요."], media: { type: "photo", src: ceramics.src, width: ceramics.width, height: ceramics.height, alt: "파란 하늘을 향해 솟은 물결무늬 테라코타 벽", caption: "가마터 도예 공방 갤러리, 부산" } },
{ id: "resumable", month: "2026-09", date: "9월 18일", iso: "2026-09-18", version: "4.12.0", kind: "improved", title: "연결이 끊겨도 업로드를 이어서 해요", summary: "큰 파일은 처음부터 다시 올리지 않고 마지막으로 끝낸 조각부터 이어서 올려요.", details: ["파일을 8MB 조각으로 나눠 올리고, 조각마다 최대 5번 다시 시도해요.", "페이지를 새로 고쳐도 업로드 대기열이 남아 있어요."] },
{ id: "currency", month: "2026-09", date: "9월 9일", iso: "2026-09-09", version: "4.11.2", kind: "fixed", title: "통화를 바꿔도 할인 코드가 유지돼요", summary: "결제 중 원화에서 달러로 바꿔도 적용한 코드가 사라지지 않아요.", details: ["4.11.0 이후 결제의 0.4%에서 생긴 문제였어요.", "이제 통화를 바꿀 때마다 서버에서 합계를 다시 계산해요."] },
{ id: "webhooks", month: "2026-09", date: "9월 2일", iso: "2026-09-02", version: "4.11.0", kind: "new", title: "주문 웹훅", summary: "주문이 결제, 환불, 배송되면 서명된 요청을 받아요.", details: ["모든 요청에 시각이 담긴 서명이 붙어요.", "전송에 실패하면 72시간 동안 간격을 늘려 가며 다시 보내요."], media: { type: "code", file: "webhooks.ts", code: webhookSnippet } },
{ id: "covers", month: "2026-08", date: "8월 26일", iso: "2026-08-26", version: "4.10.0", kind: "new", title: "전체 폭 커버가 있는 저널 글", summary: "모든 화면에서 가장자리까지 꽉 차는 사진 한 장으로 이야기를 시작해요.", details: ["초점 위치를 정하면 좁은 폰 화면에서도 자르는 위치가 유지돼요.", "커버는 흐릿한 미리보기를 먼저 보여 주고 곧 원본으로 바뀌어요."], media: { type: "photo", src: journal.src, width: journal.width, height: journal.height, alt: "노을 무렵 바위산이 비치는 고요한 알프스 호수", caption: "스튜디오 온새미로의 저널 커버, 서울" } },
{ id: "avif", month: "2026-08", date: "8월 26일", iso: "2026-08-26", version: "4.10.0", kind: "improved", title: "모바일에서 상품 페이지가 38% 빨라졌어요", summary: "이미지를 AVIF로 보내고 기기마다 알맞은 크기를 골라 줘요.", details: ["최대 콘텐츠 표시 시간의 중앙값이 2.9초에서 1.8초로 줄었어요.", "오래된 브라우저에는 WebP나 JPEG로 보내요."] },
{ id: "inventory", month: "2026-08", date: "8월 14일", iso: "2026-08-14", version: "4.9.3", kind: "fixed", title: "세일 중에 재고가 음수가 되지 않아요", summary: "동시에 결제해도 재고를 한 번에 확보해요.", details: ["두 사람이 같은 순간에 마지막 상품을 살 수 없어요.", "8월 9일부터 13일까지 초과 판매된 주문은 자동으로 환불했어요."] },
{ id: "shortcuts", month: "2026-08", date: "8월 5일", iso: "2026-08-05", version: "4.9.0", kind: "improved", title: "에디터 단축키", summary: "마우스 없이 블록을 옮기고, 복제하고, 게시해요.", details: ["어디서든 ⌘K로 명령 메뉴를 열어요.", "⌘D로 선택한 블록을 복제해요.", "⇧⌘P로 현재 페이지를 게시해요."] },
{ id: "variants", month: "2026-07", date: "7월 22일", iso: "2026-07-22", version: "4.8.0", kind: "new", title: "옵션별 사진", summary: "색상이나 크기마다 상품 페이지에 자기 갤러리를 보여 줄 수 있어요.", details: ["구매자가 옵션을 고르면 사진이 바로 바뀌어요.", "사진이 없는 옵션은 상품 갤러리를 대신 보여 줘요."], media: { type: "photo", src: bottle.src, width: bottle.width, height: bottle.height, alt: "저녁 햇살이 드는 바 테이블 위의 레드 와인 병", caption: "발효연구소의 와인 500ml, 대구" } },
{ id: "timezone", month: "2026-07", date: "7월 10일", iso: "2026-07-10", version: "4.7.1", kind: "fixed", title: "예약 게시가 스튜디오 시간대를 따라요", summary: "서울 기준 오전 9시로 예약한 글이 UTC 오전 9시에 올라가지 않아요.", details: ["기존 예약은 7월 10일에 바로잡았어요.", "예약 화면의 모든 시간 옆에 시간대를 보여 줘요."] },
{ id: "domains", month: "2026-07", date: "7월 3일", iso: "2026-07-03", version: "4.7.0", kind: "improved", title: "커스텀 도메인을 1분 안에 확인해요", summary: "DNS 레코드 두 개를 추가하면 하든이 몇 초마다 확인해요.", details: ["레코드가 확인되는 즉시 인증서를 발급해요.", "예전에는 확인에 최대 한 시간이 걸렸어요."], media: { type: "code", file: "DNS 레코드", code: dnsSnippet } },
{ id: "rooms", month: "2026-06", date: "6월 24일", iso: "2026-06-24", version: "4.6.0", kind: "new", title: "쇼핑 가능한 공간 사진", summary: "인테리어 사진 위에 상품을 바로 태그하고, 태그마다 결제 페이지를 연결해요.", details: ["사진을 자르거나 크기를 바꿔도 태그가 따라가요.", "사진 한 장에 상품을 최대 12개까지 달 수 있어요."], media: { type: "photo", src: interior.src, width: interior.width, height: interior.height, alt: "목재 보와 아치형 창, 크림색 소파가 있는 밝은 거실", caption: "메종 오베르의 공간, 제주" } },
{ id: "csv", month: "2026-06", date: "6월 12일", iso: "2026-06-12", version: "4.5.2", kind: "fixed", title: "CSV 내보내기에서 이름이 깨지지 않아요", summary: "홍길동, Zoë, Håkon 같은 이름이 모든 스프레드시트 앱에서 제대로 열려요.", details: ["내보낸 파일은 바이트 순서 표시가 있는 UTF-8로 저장해요.", "4.5.0 이후에 만든 파일은 다시 내보내 주세요."] },
{ id: "search", month: "2026-06", date: "6월 3일", iso: "2026-06-03", version: "4.5.0", kind: "improved", title: "미디어 검색이 피사체와 색상을 이해해요", summary: "사진에 담긴 내용을 입력하면 라이브러리에서 찾아 줘요.", details: ["이미지 4만 장을 200ms 안에 검색해요.", "거의 같은 사진은 묶어서 한 장만 보여 줘요."], media: { type: "photo", src: seaAtDusk.src, width: seaAtDusk.width, height: seaAtDusk.height, alt: "수평선 위로 낮은 섬이 보이는 해 질 녘의 잔잔한 바다", caption: "미디어 라이브러리에서 “해 질 녘 바다”를 검색한 결과" } },
];
const blurSoft = `blur(${motionTokens.blur.soft}px)`;
const blurSubtle = `blur(${motionTokens.blur.subtle}px)`;
const none = "blur(0px)";
const instant: Transition = { duration: 0 };
const exitSpring: Transition = motionTokens.spring.settle;
/** Values rise when they grow and fall when they shrink; the outgoing value leaves the opposite way. */
const roll: Variants = {
enter: (direction: number) => ({ opacity: 0, y: `${direction * 55}%`, filter: blurSubtle }),
center: { opacity: 1, y: "0%", filter: none },
exit: (direction: number) => ({ opacity: 0, y: `${direction * -55}%`, filter: blurSubtle }),
};
const rise: Variants = {
enter: (direction: number) => ({ opacity: 0, y: direction * 14, filter: blurSoft }),
center: { opacity: 1, y: 0, filter: none },
exit: (direction: number) => ({ opacity: 0, y: direction * -14, filter: blurSoft, transition: { duration: motionTokens.duration.exit, ease: [...motionTokens.ease.standard] } }),
};
/** "2026년 9월"처럼 연도와 월이 함께 있으면 연도를 흐리게, 월을 진하게 보여 줘요. */
function monthLabel(label: string) {
const [year, ...rest] = label.split(" ");
return rest.length ? <><span className={styles.monthYear}>{year} </span>{rest.join(" ")}</> : label;
}
function RollingNumber({ value, reduce }: { value: number; reduce: boolean }) {
const [state, setState] = useState({ value, direction: 1 });
if (state.value !== value) setState({ value, direction: value > state.value ? 1 : -1 });
return <span className={styles.roll}>
<AnimatePresence mode="popLayout" initial={false} custom={state.direction}>
<motion.span key={value} custom={state.direction} variants={roll} initial="enter" animate="center" exit="exit" transition={reduce ? instant : motionTokens.spring.snappy}>{value}</motion.span>
</AnimatePresence>
</span>;
}
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
type SubscribeState = "idle" | "editing" | "done";
/** One pill that becomes a field, then a confirmation. The shell morphs its width; the content swaps a beat later inside it. */
function SubscribeControl({ reduce, labels }: { reduce: boolean; labels: ChangelogLabels["subscribe"] }) {
const inputId = useId();
const [state, setState] = useState<SubscribeState>("idle");
const [email, setEmail] = useState("");
const [error, setError] = useState(false);
const [width, setWidth] = useState<number | null>(null);
const innerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const idleRef = useRef<HTMLButtonElement>(null);
const undoRef = useRef<HTMLButtonElement>(null);
const focusNext = useRef(false);
const [shakeScope, animate] = useAnimate<HTMLDivElement>();
useEffect(() => {
const element = innerRef.current;
if (!element) return;
const observer = new ResizeObserver(() => setWidth(element.offsetWidth));
observer.observe(element);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!focusNext.current) return;
focusNext.current = false;
const target = state === "editing" ? inputRef.current : state === "done" ? undoRef.current : idleRef.current;
target?.focus({ preventScroll: true });
}, [state]);
function go(next: SubscribeState, focus = true) { focusNext.current = focus; setState(next); }
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!emailPattern.test(email.trim())) {
playUiSound("error");
setError(true);
if (!reduce && shakeScope.current) animate(shakeScope.current, { x: [0, -6, 5, -3, 2, 0] }, { duration: motionTokens.duration.considered, ease: [...motionTokens.ease.standard] });
inputRef.current?.focus();
return;
}
playUiSound("click");
setError(false);
go("done");
}
function onKeyDown(event: KeyboardEvent<HTMLFormElement>) { if (event.key === "Escape") { playUiSound("close"); setError(false); go("idle"); } }
function onBlur(event: FocusEvent<HTMLFormElement>) { if (!event.currentTarget.contains(event.relatedTarget) && !email.trim()) { playUiSound("close"); setError(false); go("idle", false); } }
function undo() { playUiSound("click"); setEmail(""); go("idle"); }
const swap: Transition = reduce ? { duration: motionTokens.duration.instant } : { opacity: { duration: motionTokens.duration.fast, delay: motionTokens.delay.micro }, filter: { duration: motionTokens.duration.fast, delay: motionTokens.delay.micro }, scale: { ...motionTokens.spring.morph, delay: 0.03 } };
const leave = { opacity: 0, scale: 0.96, filter: blurSubtle, transition: { duration: motionTokens.duration.instant } };
const note = error ? labels.invalid : state === "done" ? labels.doneNote : "";
return <div className={styles.subscribeWrap}>
<div ref={shakeScope}>
<motion.div className={styles.subscribe} data-state={state} data-error={error || undefined} initial={false} animate={{ width: width ?? "auto" }} transition={reduce ? instant : motionTokens.spring.morph}>
<div ref={innerRef} className={styles.subscribeInner}>
<AnimatePresence mode="popLayout" initial={false}>
{state === "idle" && <motion.button key="idle" ref={idleRef} type="button" className={styles.subscribeIdle} onClick={() => { playUiSound("open"); go("editing"); }} initial={{ opacity: 0, scale: 0.96, filter: blurSubtle }} animate={{ opacity: 1, scale: 1, filter: none }} exit={leave} transition={swap}>
<UiIcon iconRole="notification" size={14} aria-hidden="true" />{labels.idle}
</motion.button>}
{state === "editing" && <motion.form key="form" className={styles.subscribeForm} noValidate onSubmit={submit} onKeyDown={onKeyDown} onBlur={onBlur} initial={{ opacity: 0, scale: 0.96, filter: blurSubtle }} animate={{ opacity: 1, scale: 1, filter: none }} exit={leave} transition={swap}>
<label className={styles.srOnly} htmlFor={inputId}>{labels.email}</label>
<input ref={inputRef} id={inputId} type="email" inputMode="email" autoComplete="email" placeholder="you@example.com" value={email} aria-invalid={error || undefined} onChange={(event) => { setEmail(event.target.value); if (error) setError(false); }} />
<button type="submit" className={styles.subscribeSubmit} aria-label={labels.submit}><UiIcon iconRole="arrow-right" size={14} /></button>
</motion.form>}
{state === "done" && <motion.div key="done" className={styles.subscribeDone} initial={{ opacity: 0, scale: 0.96, filter: blurSubtle }} animate={{ opacity: 1, scale: 1, filter: none }} exit={leave} transition={swap}>
<svg className={styles.drawnCheck} width={15} height={15} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<motion.path d="M4 12.5l5 5L20 6.5" initial={{ pathLength: 0 }} animate={{ pathLength: 1 }} transition={reduce ? instant : { duration: motionTokens.duration.considered, ease: [...motionTokens.ease.standard], delay: motionTokens.duration.instant }} />
</svg>
<span>{labels.done}</span>
<button ref={undoRef} type="button" className={styles.subscribeUndo} onClick={undo} aria-label={fill(labels.undoLabel, { email: email.trim() })}>{labels.undo}</button>
</motion.div>}
</AnimatePresence>
</div>
</motion.div>
</div>
<p className={styles.subscribeNote} data-error={error || undefined} role="status" aria-live="polite">
<AnimatePresence mode="popLayout" initial={false}>
{note && <motion.span key={note} initial={{ opacity: 0, y: -4, filter: blurSubtle }} animate={{ opacity: 1, y: 0, filter: none }} exit={{ opacity: 0, transition: { duration: motionTokens.duration.instant } }} transition={reduce ? instant : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}>{note}</motion.span>}
</AnimatePresence>
</p>
</div>;
}
function EntryMedia({ media }: { media: Media }) {
if (media.type === "photo") {
return <figure className={styles.photo}>
<div className={styles.photoFrame}>{typeof media.src === "string"
? <Image src={media.src} alt={media.alt} width={media.width ?? 1600} height={media.height ?? 1067} sizes="(max-width: 700px) 100vw, 560px" />
: <Image src={media.src} alt={media.alt} placeholder="blur" sizes="(max-width: 700px) 100vw, 560px" />}</div>
<figcaption>{media.caption}</figcaption>
</figure>;
}
return <div className={styles.code}>
<div className={styles.codeHead}><span>{media.file}</span><CopyButton value={media.code} label={`${media.file} 복사하기`} iconOnly variant="plain" /></div>
<pre><code>{media.code}</code></pre>
</div>;
}
function EntryRow({ entry, open, onToggle, reduce, kindLabel }: { entry: Entry; open: boolean; onToggle: () => void; reduce: boolean; kindLabel: string }) {
const panelId = useId();
const layout: Transition = reduce ? instant : motionTokens.spring.smooth;
return <motion.li
layout="position"
className={styles.entry}
data-open={open || undefined}
initial={reduce ? { opacity: 0 } : { opacity: 0, filter: blurSubtle }}
animate={{ opacity: 1, filter: none }}
exit={reduce ? { opacity: 0, transition: { duration: motionTokens.duration.instant } } : { opacity: 0, scale: 0.98, filter: blurSubtle, transition: { duration: motionTokens.duration.exit, ease: [...motionTokens.ease.standard] } }}
transition={reduce ? { duration: 0, opacity: { duration: motionTokens.duration.instant } } : { opacity: { duration: motionTokens.duration.standard }, filter: { duration: motionTokens.duration.standard }, layout }}
>
<button type="button" className={styles.row} aria-expanded={open} aria-controls={panelId} onClick={onToggle}>
<span className={styles.meta}>
<time dateTime={entry.iso}>{entry.date}</time>
<span className={styles.version}>v{entry.version}</span>
</span>
<span className={styles.body}>
<span className={styles.kind} data-kind={entry.kind}><i aria-hidden="true" />{kindLabel}</span>
<span className={styles.title}>{entry.title}</span>
<span className={styles.summary}>{entry.summary}</span>
</span>
<motion.span className={styles.chevron} aria-hidden="true" initial={false} animate={{ rotate: open ? 180 : 0 }} transition={reduce ? instant : motionTokens.spring.snappy}><UiIcon iconRole="chevron-down" size={16} /></motion.span>
</button>
<AnimatePresence initial={false}>
{open && <motion.div key="panel" id={panelId} className={styles.panel} initial={{ height: 0 }} animate={{ height: "auto" }} exit={{ height: 0, transition: reduce ? instant : exitSpring }} transition={reduce ? instant : motionTokens.spring.smooth}>
<motion.div
className={styles.panelInner}
initial={{ opacity: 0, y: 8, filter: blurSoft }}
animate={{ opacity: 1, y: 0, filter: none }}
exit={{ opacity: 0, transition: { duration: motionTokens.duration.instant } }}
transition={reduce ? { duration: 0, opacity: { duration: motionTokens.duration.instant } } : { y: { ...motionTokens.spring.smooth, delay: motionTokens.delay.micro }, opacity: { duration: motionTokens.duration.standard, delay: motionTokens.delay.micro }, filter: { duration: motionTokens.duration.standard, delay: motionTokens.delay.micro } }}
>
<ul className={styles.details}>{entry.details.map((detail, index) => <li key={index}>{detail}</li>)}</ul>
{entry.media && <EntryMedia media={entry.media} />}
</motion.div>
</motion.div>}
</AnimatePresence>
</motion.li>;
}
export interface ChangelogFeedProps {
/** Release notes, newest first. Each entry belongs to one of `months` by key. */
entries?: ChangelogEntry[];
/** Months shown in the month bar, newest first. */
months?: ChangelogMonth[];
/** The heading of the feed. Pass `null` where the page already has its own title: the feed then shows only the subtitle and is named by `labels.list`. */
title?: string | null;
/** One line under the title, such as the latest release. */
subtitle?: string;
/** Closing line at the end of the list. */
endNote?: string;
/** 처음에 열려 있는 항목의 id예요. 기본은 가장 최근 항목이에요. */
defaultOpen?: string[];
/** The feed's own words, for another language or another tone. A group is usually a month; pass versions as `months` to group by release. */
labels?: Partial<Omit<ChangelogLabels, "kinds" | "subscribe">> & { kinds?: Partial<ChangelogLabels["kinds"]>; subscribe?: Partial<ChangelogLabels["subscribe"]> };
/** Shows the subscribe control beside the title. Turn it off where there is nothing to subscribe to. Defaults to true. */
showSubscribe?: boolean;
className?: string;
}
export function ChangelogFeed({
entries = exampleEntries,
months = exampleMonths,
title = "변경 내역",
subtitle = "하든 4.12가 2026년 9월 18일에 나왔어요",
endNote = "6월의 하든 4.5 이후 변경 내역은 여기까지예요.",
defaultOpen,
labels: labelOverrides,
showSubscribe = true,
className,
}: ChangelogFeedProps = {}) {
const uid = useId();
const labels: ChangelogLabels = useMemo(
() => ({ ...defaultLabels, ...labelOverrides, kinds: { ...defaultLabels.kinds, ...labelOverrides?.kinds }, subscribe: { ...defaultLabels.subscribe, ...labelOverrides?.subscribe } }),
[labelOverrides],
);
const kinds = useMemo(() => kindIds.map((id) => ({ id, label: labels.kinds[id] })), [labels]);
const totalCount = entries.length;
const kindCounts = useMemo(() => kinds.reduce<Record<Kind, number>>((counts, kind) => ({ ...counts, [kind.id]: entries.filter((entry) => entry.kind === kind.id).length }), { new: 0, improved: 0, fixed: 0 }), [entries, kinds]);
const monthOrder = useCallback((key: string) => months.findIndex((month) => month.key === key), [months]);
const reduce = useReducedMotion() ?? false;
const [filters, setFilters] = useState<Kind[]>([]);
const [open, setOpen] = useState<string[]>(() => defaultOpen ?? (entries[0] ? [entries[0].id] : []));
const [active, setActive] = useState({ key: months[0]?.key ?? "", direction: 1 });
const scrollRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const groupRefs = useRef(new Map<string, HTMLElement>());
const frame = useRef(0);
const visible = useMemo(() => entries.filter((entry) => filters.length === 0 || filters.includes(entry.kind)), [entries, filters]);
const groups = useMemo(() => months.map((month) => ({ ...month, items: visible.filter((entry) => entry.month === month.key) })).filter((group) => group.items.length > 0), [months, visible]);
const current = groups.find((group) => group.key === active.key) ?? groups[0];
const sync = useCallback(() => {
const scroller = scrollRef.current;
if (!scroller || groups.length === 0) return;
const line = scroller.getBoundingClientRect().top + 28;
let key = groups[0]!.key;
for (const group of groups) {
const element = groupRefs.current.get(group.key);
if (element && element.getBoundingClientRect().top <= line) key = group.key;
}
if (scroller.scrollTop > 0 && scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2) key = groups[groups.length - 1]!.key;
setActive((previous) => previous.key === key ? previous : { key, direction: monthOrder(key) > monthOrder(previous.key) ? 1 : -1 });
}, [groups, monthOrder]);
const onScroll = useCallback(() => {
if (frame.current) return;
frame.current = requestAnimationFrame(() => { frame.current = 0; sync(); });
}, [sync]);
useEffect(() => {
const list = listRef.current;
if (!list) return;
const observer = new ResizeObserver(() => onScroll());
observer.observe(list);
return () => observer.disconnect();
}, [onScroll]);
useEffect(() => () => cancelAnimationFrame(frame.current), []);
function toggleFilter(kind: Kind) { playUiSound("tick"); setFilters((previous) => previous.includes(kind) ? previous.filter((item) => item !== kind) : [...previous, kind]); }
function toggleEntry(id: string) { playUiSound("tick"); setOpen((previous) => previous.includes(id) ? previous.filter((item) => item !== id) : [...previous, id]); }
function jumpTo(key: string) {
const scroller = scrollRef.current;
const element = groupRefs.current.get(key);
if (!scroller || !element) return;
playUiSound("tick");
const top = scroller.scrollTop + element.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
scroller.scrollTo({ top: Math.max(0, top - 1), behavior: reduce ? "auto" : "smooth" });
}
const layout: Transition = reduce ? instant : motionTokens.spring.smooth;
return <section className={[styles.feed, className].filter(Boolean).join(" ")} aria-labelledby={title ? `${uid}-title` : undefined} aria-label={title ? undefined : labels.list}>
<header className={styles.header}>
<div className={styles.heading}>
{title && <h2 id={`${uid}-title`}>{title}</h2>}
{subtitle && <p>{subtitle}</p>}
</div>
{showSubscribe && <SubscribeControl reduce={reduce} labels={labels.subscribe} />}
</header>
<div className={styles.toolbar}>
<div className={styles.chips} role="group" aria-label={labels.filterGroup}>
{kinds.map((kind) => {
const on = filters.includes(kind.id);
return <button key={kind.id} type="button" className={styles.chip} data-kind={kind.id} aria-pressed={on} onClick={() => toggleFilter(kind.id)}>
<motion.span className={styles.chipMark} aria-hidden="true" initial={false} animate={{ width: on ? 16 : 7, height: on ? 16 : 7 }} transition={reduce ? instant : motionTokens.spring.morph}>
<motion.span className={styles.chipTick} initial={false} animate={{ opacity: on ? 1 : 0, scale: on ? 1 : 0.4 }} transition={reduce ? instant : { ...motionTokens.spring.snappy, delay: on ? motionTokens.delay.micro : 0 }}><UiIcon iconRole="check" size={11} strokeWidth={2.6} /></motion.span>
</motion.span>
<span>{kind.label}</span>
<span className={styles.chipCount}>{kindCounts[kind.id]}</span>
</button>;
})}
<AnimatePresence initial={false}>
{filters.length > 0 && <motion.button key="clear" type="button" className={styles.clear} onClick={() => { playUiSound("tick"); setFilters([]); }} initial={{ opacity: 0, x: -6, filter: blurSubtle }} animate={{ opacity: 1, x: 0, filter: none }} exit={{ opacity: 0, x: -4, filter: blurSubtle, transition: { duration: motionTokens.duration.instant } }} transition={reduce ? instant : motionTokens.spring.snappy}>{labels.clearFilters}</motion.button>}
</AnimatePresence>
</div>
<p className={styles.shown} aria-hidden="true">
<motion.span className={styles.shownCount} layout="position" transition={layout}><RollingNumber value={visible.length} reduce={reduce} /></motion.span>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span key={filters.length === 0 ? "all" : "some"} initial={{ opacity: 0, filter: blurSubtle }} animate={{ opacity: 1, filter: none }} exit={{ opacity: 0, transition: { duration: motionTokens.duration.instant } }} transition={reduce ? instant : { duration: motionTokens.duration.standard }}>{filters.length === 0 ? labels.shownAll : fill(labels.shownSome, { total: totalCount })}</motion.span>
</AnimatePresence>
</p>
<p className={styles.srOnly} role="status" aria-live="polite">{fill(filters.length > 0 ? labels.statusSome : labels.statusAll, { total: totalCount, shown: visible.length })}</p>
</div>
<div className={styles.monthBar}>
<div className={styles.monthLabel} aria-hidden="true">
<span className={styles.monthText}>
<AnimatePresence mode="popLayout" initial={false} custom={active.direction}>
<motion.span key={current?.key ?? "none"} custom={active.direction} variants={rise} initial="enter" animate="center" exit="exit" transition={reduce ? instant : motionTokens.spring.morph}>{current ? monthLabel(current.label) : labels.empty}</motion.span>
</AnimatePresence>
</span>
<motion.span className={styles.monthCount} layout="position" transition={layout}><RollingNumber value={current?.items.length ?? 0} reduce={reduce} />{labels.updatesAfterNumber}</motion.span>
</div>
<nav className={styles.monthNav} aria-label={labels.groupNav}>
{groups.map((group) => {
const selected = group.key === current?.key;
return <button key={group.key} type="button" aria-label={fill(labels.viewGroup, { label: group.label })} aria-current={selected ? "true" : undefined} onClick={() => jumpTo(group.key)}>
{selected && <motion.span layoutId={`${uid}-month`} className={styles.monthPill} transition={reduce ? instant : motionTokens.spring.morph} />}
<span className={styles.monthShort}>{group.short}</span>
</button>;
})}
</nav>
</div>
<motion.div layoutScroll ref={scrollRef} className={styles.scroller} onScroll={onScroll} tabIndex={0} aria-label={labels.list}>
<div ref={listRef} className={styles.list}>
<AnimatePresence mode="popLayout" initial={false}>
{groups.map((group, index) => <motion.section
key={group.key}
layout="position"
className={styles.group}
aria-labelledby={`${uid}-${group.key}`}
ref={(element: HTMLElement | null) => { if (element) groupRefs.current.set(group.key, element); else groupRefs.current.delete(group.key); }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: motionTokens.duration.exit } }}
transition={{ layout, opacity: { duration: reduce ? motionTokens.duration.instant : motionTokens.duration.standard } }}
>
<h3 id={`${uid}-${group.key}`} className={index === 0 ? styles.srOnly : styles.groupTitle}>{group.label}</h3>
<ul className={styles.entries}>
<AnimatePresence mode="popLayout" initial={false}>
{group.items.map((entry) => <EntryRow key={entry.id} entry={entry} open={open.includes(entry.id)} onToggle={() => toggleEntry(entry.id)} reduce={reduce} kindLabel={labels.kinds[entry.kind]} />)}
</AnimatePresence>
</ul>
</motion.section>)}
</AnimatePresence>
<motion.p layout="position" transition={{ layout }} className={styles.endCap}>{endNote}</motion.p>
</div>
</motion.div>
</section>;
}
export default ChangelogFeed;
Used items
- Copy buttonComponent
A button that copies a value to the clipboard and confirms it.