Block
Blog fan
A blog band that fans the newest posts as overlapping cards. With a single post, the places left over are filled with example cards.
- #arc
- #block
- #blog
- #posts
- #cards
- #fan
- #section
- #placeholder
1280px
Install
One command installs the files and packages this item needs.
TerminalTerminalTerminalbash
npx shadcn@latest add https://dsui.vercel.app/r/blog-fan.jsonFiles
components/arc/blocks/blog-fan/blog-fan.module.csscomponents/arc/blocks/blog-fan/blog-fan.tsxcomponents/arc/blocks/blog-fan/fan.ts
Registry items it pulls in
Source
View as Markdownblog-fan.module.cssblog-fan.module.cssblog-fan.module.csscss
/* The latest posts as a fan of overlapping cards. Card colors come from the theme's tokens (surface, inverse, accent, highlight); a page that wants
fixed brand fields sets --fan-white, --fan-ink, --fan-blue, --fan-yellow and --fan-mute (and the matching -ink and -muted) on the band's parent.
Hovering a card pushes its neighbours away and shrinks them a little. */
.band { width: 100%; overflow: hidden; padding-block: clamp(96px, 13vw, 200px) clamp(72px, 10vw, 150px); background: var(--surface); color: var(--foreground); }
.inner { width: 100%; max-width: 1240px; margin: 0 auto; padding-inline: max(16px, env(safe-area-inset-left, 0px)) max(16px, env(safe-area-inset-right, 0px)); }
.head { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 16px 32px; }
.title { margin: 0; font-family: var(--home-font, var(--font-display, inherit)); font-size: clamp(34px, 5.6vw, 72px); font-weight: 900; line-height: 1.04; letter-spacing: -.045em; text-wrap: balance; }
.lead { max-width: 40ch; margin: 8px 0 0; color: var(--text-secondary); font-family: var(--home-font, var(--font-display, inherit)); font-size: clamp(15px, 1.5vw, 18px); font-weight: 500; line-height: 1.6; word-break: keep-all; }
.fan { position: relative; height: var(--fh, 360px); margin-top: clamp(20px, 3vw, 40px); }
.slot { position: absolute; top: 0; left: 0; z-index: var(--z, 1); width: var(--cw); height: var(--ch); transform: translate(calc(var(--x) + var(--sx, 0px)), var(--y)) rotate(var(--rot)) scale(var(--ss, 1)); transition: transform .9s cubic-bezier(.2, .9, .2, 1) calc(var(--i) * 70ms), opacity .6s calc(var(--i) * 70ms); }
.fan[data-settled="true"] .slot { transition: transform .85s cubic-bezier(.26, 1.75, .42, 1), opacity .4s; }
.fan:not([data-in="true"]) .slot { opacity: 0; transform: translate(var(--cx), calc(var(--y) + 50px)) rotate(0deg) scale(.7); }
.slot:hover, .slot:focus-within { z-index: 30; }
.card { position: relative; display: flex; width: 100%; height: 100%; flex-direction: column; justify-content: space-between; padding: calc(var(--cw) * .07); border-radius: calc(var(--cw) * .09); background: var(--bg); box-shadow: 0 1.4em 2.6em rgba(26, 26, 26, .16), 0 .15em .4em rgba(26, 26, 26, .08); color: var(--fg); font-size: calc(var(--cw) * .056); text-decoration: none; transition: transform .45s cubic-bezier(.3, 1.4, .5, 1), box-shadow .3s; animation: bob var(--fd, 7s) ease-in-out infinite alternate; animation-delay: var(--fo, 0s); -webkit-tap-highlight-color: transparent; }
.card:hover, .card:focus-visible { box-shadow: 0 2.6em 4.2em rgba(26, 26, 26, .3); transform: translateY(-1.6em) scale(1.14) rotate(calc(var(--rot) * -1)); animation-play-state: paused; }
.card:focus-visible { outline: 3px solid var(--accent); outline-offset: 4px; }
.card:active { transform: translateY(-1.2em) scale(1.08) rotate(calc(var(--rot) * -1)); }
@keyframes bob { from { translate: 0 -.25em; } to { translate: 0 .3em; } }
.white { --bg: var(--fan-white, var(--surface-raised)); --fg: var(--fan-white-ink, var(--foreground)); --mut: var(--fan-white-muted, var(--text-secondary)); }
.ink { --bg: var(--fan-ink, var(--inverse)); --fg: var(--fan-ink-ink, var(--inverse-foreground)); --mut: var(--fan-ink-muted, color-mix(in srgb, var(--inverse-foreground) 62%, transparent)); }
.blue { --bg: var(--fan-blue, var(--accent)); --fg: var(--fan-blue-ink, var(--accent-foreground)); --mut: var(--fan-blue-muted, var(--accent-foreground-muted)); }
.yellow { --bg: var(--fan-yellow, var(--highlight)); --fg: var(--fan-yellow-ink, var(--highlight-foreground)); --mut: var(--fan-yellow-muted, color-mix(in srgb, var(--highlight-foreground) 68%, transparent)); }
.mute { --bg: var(--fan-mute, var(--surface-muted)); --fg: var(--fan-mute-ink, var(--foreground)); --mut: var(--fan-mute-muted, var(--text-secondary)); }
.chip { align-self: flex-start; padding: .35em .9em; border-radius: 999px; background: color-mix(in srgb, var(--fg) 12%, transparent); font-family: var(--home-font, var(--font-display, inherit)); font-size: .78em; font-weight: 800; line-height: 1.2; letter-spacing: 0; }
.tt { display: -webkit-box; margin: 0; overflow: hidden; font-family: var(--home-font, var(--font-display, inherit)); font-size: 1.55em; font-weight: 800; line-height: 1.22; letter-spacing: -.04em; text-wrap: balance; word-break: keep-all; -webkit-box-orient: vertical; -webkit-line-clamp: 3; }
.ft { display: flex; align-items: center; justify-content: space-between; gap: .6em; color: var(--mut); font-family: var(--home-font, var(--font-display, inherit)); font-size: .82em; font-weight: 600; line-height: 1; }
.go { display: grid; width: 2.3em; height: 2.3em; flex: none; place-items: center; border-radius: 50%; background: var(--fg); color: var(--bg); }
.go svg { width: 55%; height: 55%; fill: none; stroke: currentColor; stroke-width: 2.3; stroke-linecap: round; stroke-linejoin: round; }
.dec { position: absolute; top: -.5em; right: -.4em; width: 5em; height: 5em; opacity: .9; pointer-events: none; }
.dec svg { width: 100%; height: 100%; overflow: visible; }
/* Example cards (no posts yet): only the places where content will go. */
.sk .chip { width: 5.2em; height: 1.9em; padding: 0; }
.lines { display: grid; gap: .55em; }
.lines i { display: block; height: 1.25em; border-radius: 999px; background: color-mix(in srgb, var(--fg) 20%, transparent); }
.lines i:nth-child(1) { width: 92%; }
.lines i:nth-child(2) { width: 74%; }
.lines i:nth-child(3) { width: 48%; }
.sk .ft span { display: block; width: 4.4em; height: .9em; border-radius: 999px; background: color-mix(in srgb, var(--fg) 16%, transparent); }
.soon { display: grid; justify-items: center; gap: 10px; margin: clamp(8px, 2vw, 24px) auto 0; text-align: center; }
.soon b { font-family: var(--home-font, var(--font-display, inherit)); font-size: clamp(22px, 3.2vw, 36px); font-weight: 900; line-height: 1.2; letter-spacing: -.04em; }
.soon span { max-width: 44ch; color: var(--text-secondary); font-family: var(--home-font, var(--font-display, inherit)); font-size: clamp(14px, 1.4vw, 17px); font-weight: 500; line-height: 1.6; word-break: keep-all; }
.more { display: flex; justify-content: center; margin-top: clamp(18px, 3vw, 32px); }
@media (prefers-reduced-motion: reduce) {
.slot { transition: none !important; }
.fan:not([data-in="true"]) .slot { opacity: 1; transform: translate(var(--x), var(--y)) rotate(var(--rot)); }
.card { animation: none !important; }
}
:global(:root[data-motion="reduce"]) .card { animation: none; }
:global(:root[data-motion="reduce"]) .slot { transition: none; }
:global(:root[data-motion="reduce"]) .fan:not([data-in="true"]) .slot { opacity: 1; transform: translate(var(--x), var(--y)) rotate(var(--rot)); }
blog-fan.tsxblog-fan.tsxblog-fan.tsxtsx
"use client";
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, ReactNode } from "react";
import { ArcLink } from "../../lib/arc-provider";
import { useReducedMotion } from "../../lib/use-reduced-motion";
import { ButtonLink } from "../../button-link/button-link";
import { playUiSound } from "../../sound/play";
import { cardCount, fanLayout, neighbourShift } from "./fan";
import styles from "./blog-fan.module.css";
export type BlogFanPost = { title: string; tag: string; date: string; href: string };
export interface BlogFanProps {
/** The newest posts, newest first. The fan fills the places left over with example cards, so one post is enough. */
posts?: BlogFanPost[];
title?: string;
lead?: string;
/** Accessible name of the fan of cards. */
fanLabel?: string;
/** Shown under the fan while there are no posts at all. */
emptyTitle?: string;
emptyNote?: string;
/** Where the "more" button goes. Leave it out to hide the button. */
moreHref?: string;
moreLabel?: string;
/** Replaces the default "more" button, for a page that has its own. */
more?: ReactNode;
className?: string;
}
const TONES = ["mute", "ink", "blue", "white", "yellow", "ink", "white"] as const;
const DEC: ReactNode[] = [
<svg key="a" viewBox="0 0 100 100"><circle cx="62" cy="38" r="26" fill="var(--highlight)" /><path d="M18 74 C 30 56, 48 88, 62 66" stroke="var(--accent)" strokeWidth="7" strokeLinecap="round" fill="none" /></svg>,
<svg key="b" viewBox="0 0 100 100"><circle cx="60" cy="40" r="28" fill="var(--accent)" /><circle cx="34" cy="68" r="12" fill="var(--highlight)" /></svg>,
<svg key="c" viewBox="0 0 100 100"><circle cx="58" cy="42" r="26" fill="var(--highlight)" /><path d="M30 30l8 14-14 6" stroke="var(--surface)" strokeWidth="6" strokeLinecap="round" strokeLinejoin="round" fill="none" /></svg>,
<svg key="d" viewBox="0 0 100 100"><circle cx="60" cy="40" r="26" fill="var(--inverse)" /><path d="M40 40h40M60 20v40" stroke="var(--highlight)" strokeWidth="7" strokeLinecap="round" /></svg>,
<svg key="e" viewBox="0 0 100 100"><circle cx="58" cy="42" r="28" fill="var(--surface-raised)" /><circle cx="58" cy="42" r="12" fill="var(--accent)" /></svg>,
];
const ARROW = <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6" /></svg>;
/**
* The newest posts as a fan of overlapping cards. The fan always spreads over the full width: the posts come first and example cards
* (only the places where a title, a tag and a date will go) fill the places left over, so a single post still looks like a full band.
*/
export function BlogFan({
posts = [],
title = "새로 올라온 글",
lead = "만들면서 배운 것을 짧게 적어 둬요.",
fanLabel = "새로 올라온 글",
emptyTitle = "첫 글을 준비하고 있어요",
emptyNote = "곧 이 자리에 새 글이 올라와요.",
moreHref,
moreLabel = "글 모두 보기",
more,
className,
}: BlogFanProps) {
const reduced = useReducedMotion();
const bandRef = useRef<HTMLElement>(null);
const fanRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(900);
const [seen, setSeen] = useState(false);
const [settled, setSettled] = useState(false);
const [focus, setFocus] = useState<number | null>(null);
const empty = posts.length === 0;
const n = cardCount(width);
const inView = reduced || seen;
useEffect(() => {
const el = fanRef.current;
if (!el) return;
setWidth(el.clientWidth || 900);
let timer = 0;
const ro = new ResizeObserver(() => { window.clearTimeout(timer); timer = window.setTimeout(() => setWidth(el.clientWidth || 900), 120); });
ro.observe(el);
return () => { ro.disconnect(); window.clearTimeout(timer); };
}, []);
useEffect(() => {
const el = bandRef.current;
if (!el) return;
let timer = 0;
const io = new IntersectionObserver((entries) => {
if (!entries[0].isIntersecting) return;
setSeen(true);
timer = window.setTimeout(() => setSettled(true), 1700);
io.disconnect();
}, { threshold: 0.25 });
io.observe(el);
return () => { io.disconnect(); window.clearTimeout(timer); };
}, []);
const first = fanLayout(width, n, 0);
const cards = Array.from({ length: n }, (_, i) => {
const L = fanLayout(width, n, i);
const tone = TONES[(i + (n < 7 ? 1 : 0)) % TONES.length];
const sh = !reduced && focus !== null ? neighbourShift(width, L.cw, L.x, i, focus) : { sx: 0, ss: 1 };
const slot = {
"--cw": `${L.cw.toFixed(1)}px`, "--ch": `${L.ch.toFixed(1)}px`, "--x": `${L.x.toFixed(1)}px`, "--y": `${(L.y + 8).toFixed(1)}px`,
"--cx": `${L.cx.toFixed(1)}px`, "--rot": `${L.rot.toFixed(1)}deg`, "--z": L.z, "--i": L.dist, "--sx": `${sh.sx.toFixed(1)}px`, "--ss": sh.ss.toFixed(3),
} as CSSProperties;
const cardStyle = { "--fd": `${(6 + (i % 3) * 1.1).toFixed(1)}s`, "--fo": `-${(i * 1.4).toFixed(1)}s` } as CSSProperties;
if (i >= posts.length) {
return (
<div key={i} className={styles.slot} style={slot} onPointerEnter={() => setFocus(i)}>
<div className={`${styles.card} ${styles[tone]} ${styles.sk}`} style={cardStyle} aria-hidden="true">
<span className={styles.chip} />
<span className={styles.lines}><i /><i /><i /></span>
<span className={styles.ft}><span /><b className={styles.go}>{ARROW}</b></span>
</div>
</div>
);
}
const post = posts[i];
return (
<div key={i} className={styles.slot} style={slot} onPointerEnter={() => setFocus(i)} onFocus={() => setFocus(i)}>
<ArcLink href={post.href} className={`${styles.card} ${styles[tone]}`} style={cardStyle} aria-label={post.title} onClick={() => playUiSound("click")}>
<span className={styles.dec} aria-hidden="true">{DEC[i % DEC.length]}</span>
<span className={styles.chip}>{post.tag}</span>
<h3 className={styles.tt}>{post.title}</h3>
<span className={styles.ft}><span>{post.date}</span><b className={styles.go} aria-hidden="true">{ARROW}</b></span>
</ArcLink>
</div>
);
});
return (
<section ref={bandRef} className={[styles.band, className].filter(Boolean).join(" ")} aria-labelledby="blog-fan-title">
<div className={styles.inner}>
<div className={styles.head}>
<div>
<h2 className={styles.title} id="blog-fan-title">{title}</h2>
<p className={styles.lead}>{lead}</p>
</div>
</div>
<div
ref={fanRef}
className={styles.fan}
aria-label={fanLabel}
data-in={inView}
data-settled={reduced || settled}
style={{ "--fh": `${(first.ch + first.drop + 36).toFixed(0)}px` } as CSSProperties}
onPointerLeave={() => setFocus(null)}
onBlur={() => setFocus(null)}
>
{cards}
</div>
{empty ? (
<div className={styles.soon}>
<b>{emptyTitle}</b>
<span>{emptyNote}</span>
</div>
) : null}
{more || moreHref ? (
<div className={styles.more}>
{more ?? <ButtonLink href={moreHref as string} size="lg">{moreLabel}</ButtonLink>}
</div>
) : null}
</div>
</section>
);
}
BlogFan.displayName = "BlogFan";
export default BlogFan;
fan.tsfan.tsfan.tsts
/** Layout maths of the home blog fan, apart from the component so it can be tested on its own. */
/** How many cards the fan shows at a given width. */
export const cardCount = (width: number) => (width >= 1000 ? 7 : width >= 640 ? 5 : 3);
/** Where card `i` of `n` sits in the fan: its slot width, left edge, drop and tilt. The middle card is flat and highest. */
export function fanLayout(width: number, n: number, i: number) {
const cwMax = width < 640 ? width * 0.62 : 330;
const cw = Math.min(cwMax, width / (1 + 0.64 * (n - 1)));
const ch = cw / 1.28;
const step = n === 1 ? 0 : (width - cw) / (n - 1);
const drop = cw * 0.34;
const mid = (n - 1) / 2;
const t = mid ? (i - mid) / mid : 0;
return { cw, ch, drop, x: n === 1 ? (width - cw) / 2 : i * step, y: t * t * drop, rot: t * 13, z: 10 - Math.round(Math.abs(t) * 9), dist: Math.round(Math.abs(i - mid)), cx: (width - cw) / 2 };
}
/** The neighbours of the hovered card `k`: left ones go further left, right ones further right, all a little smaller. Clamped to the fan. */
export function neighbourShift(width: number, cw: number, x: number, i: number, k: number) {
const d = i - k;
const a = Math.abs(d);
if (d === 0) return { sx: 0, ss: 1 };
const shift = (Math.sign(d) * cw * 0.3) / (1 + (a - 1) * 0.5);
const sx = Math.min(Math.max(x + shift, -cw * 0.12), width - cw * 0.88) - x;
return { sx, ss: 0.93 - Math.min(a - 1, 3) * 0.015 };
}
Used items
- Button linkComponent
An anchor that looks like the Button, for navigation that has to stay a real link.