07関連CTA — 共通タグ数から関連度を自分で求めるおすすめ
今見ている事例と他の事例がいくつのタグを共有しているかを数えて関連度スコアを自分で求め、スコアが高い事例から順に推薦カードとして並べます。
使いどころ事例詳細下部の関連コンテンツ推薦、次の事例への誘導セクション
getBoundingClientRectclassList.addtranslate(

<main class="stage">
<section class="panel">
<p class="hint" id="hint">今見ている事例のタグと重なる数を実際に数えてスコアを付け、スコア順にカードを並べ替えます</p>
<p class="baseTagRow" id="baseTagRow">現在の事例タグ: <span id="baseTags">グロース・オンボーディング・B2B</span></p>
<div class="list" id="list" role="list" aria-label="関連事例"></div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-enter;
$easing: $ease-spring;
$color: $subject-blue-deep;
@include stage($stage-orange);
.panel {
width: 100%;
max-width: 360px;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.hint {
margin: 0;
text-align: center;
color: rgba($subject-ink, .72);
font-weight: 400;
font-size: 10px;
line-height: 1.35;
}
.baseTagRow {
margin: 0;
text-align: center;
color: $subject-ink;
font-weight: 600;
font-size: 11px;
}
.list {
display: flex;
flex-direction: column;
gap: $sp-1;
}
.card {
border-radius: $r-control;
background: rgba($subject-cream, .55);
padding: $sp-2 $sp-3;
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
transform-origin: center;
}
.card--flip {
transition: transform $duration $easing;
}
.cardName {
margin: 0;
color: $subject-ink;
font-weight: 600;
font-size: 11px;
}
.cardScore {
margin: 0;
display: flex;
align-items: baseline;
gap: 0;
flex: 0 0 auto;
}
.cardScoreNum {
color: $color;
font-weight: 700;
font-size: 15px;
font-variant-numeric: tabular-nums;
}
.cardScoreLabel {
color: rgba($subject-ink, .72);
font-weight: 500;
font-size: 9px;
}
@media (max-width: 340px) {
.panel { max-width: 280px; gap: $sp-1; }
.list { gap: 0; }
.card { padding: $sp-1 $sp-2; }
.baseTagRow { font-size: 9px; }
.cardName { font-size: 9px; }
.cardScoreNum { font-size: 12px; }
.cardScoreLabel { font-size: 8px; }
.hint { font-size: 8px; line-height: 1.2; }
}
@media (prefers-reduced-motion: reduce) {
.card--flip {
transition: none;
}
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.panel {
width: 100%;
max-width: 360px;
display: flex;
flex-direction: column;
gap: 8px;
}
.hint {
margin: 0;
text-align: center;
color: rgba(23, 20, 26, 0.72);
font-weight: 400;
font-size: 10px;
line-height: 1.35;
}
.baseTagRow {
margin: 0;
text-align: center;
color: #17141a;
font-weight: 600;
font-size: 11px;
}
.list {
display: flex;
flex-direction: column;
gap: 4px;
}
.card {
border-radius: 12px;
background: rgba(255, 247, 230, 0.55);
padding: 8px 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
transform-origin: center;
}
.card--flip {
transition: transform 600ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cardName {
margin: 0;
color: #17141a;
font-weight: 600;
font-size: 11px;
}
.cardScore {
margin: 0;
display: flex;
align-items: baseline;
gap: 0;
flex: 0 0 auto;
}
.cardScoreNum {
color: #0a4fe8;
font-weight: 700;
font-size: 15px;
font-variant-numeric: tabular-nums;
}
.cardScoreLabel {
color: rgba(23, 20, 26, 0.72);
font-weight: 500;
font-size: 9px;
}
@media (max-width: 340px) {
.panel {
max-width: 280px;
gap: 4px;
}
.list {
gap: 0;
}
.card {
padding: 4px 8px;
}
.baseTagRow {
font-size: 9px;
}
.cardName {
font-size: 9px;
}
.cardScoreNum {
font-size: 12px;
}
.cardScoreLabel {
font-size: 8px;
}
.hint {
font-size: 8px;
line-height: 1.2;
}
}
@media (prefers-reduced-motion: reduce) {
.card--flip {
transition: none;
}
}// 関連事例 CTA(07) — 카드마다 태그 목록을 갖고, 지금 보는 사례 태그와 겹치는 개수를 매번
// 직접 세어 관련도 점수로 삼는다. 점수 내림차순으로 다시 정렬하고, FLIP(측정 전 위치 기록 →
// DOM 갱신 → 측정 후 위치와의 차이만큼 역보정 이동)으로 순서 변화를 transform 만으로 보여준다.
(function () {
var list = document.getElementById('list');
var baseTagsEl = document.getElementById('baseTags');
if (!list || !baseTagsEl) return;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var BASE_SETS = [
['グロース', 'オンボーディング', 'B2B'],
['フィンテック', 'セキュリティ', 'ダッシュボード'],
['Eコマース', 'チェックアウト', 'モバイル']
];
var CARDS = [
{ id: 'nova', name: 'ノヴァ・グロースレポート', tags: ['グロース', 'オンボーディング', 'SaaS'] },
{ id: 'atlas', name: 'アトラス・B2B営業', tags: ['B2B', 'オンボーディング', 'ダッシュボード'] },
{ id: 'cobalt', name: 'コバルト・決済セキュリティ', tags: ['フィンテック', 'セキュリティ', 'B2B'] },
{ id: 'silverline', name: 'シルバーライン・チェックアウト', tags: ['Eコマース', 'チェックアウト', 'モバイル'] },
{ id: 'terra', name: 'テラ・モバイルダッシュボード', tags: ['ダッシュボード', 'モバイル', 'グロース'] }
];
var cardEls = {};
CARDS.forEach(function (card) {
var el = document.createElement('article');
el.className = 'card';
el.setAttribute('role', 'listitem');
el.dataset.id = card.id;
el.innerHTML =
'<p class="cardName">' + card.name + '</p>' +
'<p class="cardScore"><span class="cardScoreNum">0</span><span class="cardScoreLabel">個のタグ一致</span></p>';
list.appendChild(el);
cardEls[card.id] = el;
});
function scoreFor(card, baseTags) {
var overlap = 0;
for (var i = 0; i < card.tags.length; i++) {
if (baseTags.indexOf(card.tags[i]) !== -1) overlap++;
}
return overlap;
}
function applyBase(baseTags) {
baseTagsEl.textContent = baseTags.join(' · ');
var scored = CARDS.map(function (card) {
return { card: card, score: scoreFor(card, baseTags) };
});
scored.sort(function (a, b) { return b.score - a.score; });
// FLIP: 이동 전 위치를 먼저 기록한다.
var firstRects = {};
CARDS.forEach(function (card) {
firstRects[card.id] = cardEls[card.id].getBoundingClientRect();
});
scored.forEach(function (entry) {
var el = cardEls[entry.card.id];
list.appendChild(el);
var numEl = el.querySelector('.cardScoreNum');
if (numEl) numEl.textContent = String(entry.score);
});
if (reduce) return;
// Last: 이동 후 위치를 재고, Invert: 차이를 계산해 transform 으로 되돌린 뒤 Play: 0 으로 애니메이션.
CARDS.forEach(function (card) {
var el = cardEls[card.id];
var first = firstRects[card.id];
var last = el.getBoundingClientRect();
var dx = first.left - last.left;
var dy = first.top - last.top;
if (dx === 0 && dy === 0) return;
el.style.transition = 'none';
el.style.transform = 'translate(' + dx + 'px, ' + dy + 'px)';
el.getBoundingClientRect();
el.style.transition = '';
requestAnimationFrame(function () {
el.classList.add('card--flip');
el.style.transform = '';
});
el.addEventListener('transitionend', function handler() {
el.classList.remove('card--flip');
el.removeEventListener('transitionend', handler);
});
});
}
var idx = 0;
applyBase(BASE_SETS[0]);
if (!reduce) {
setInterval(function () {
idx = (idx + 1) % BASE_SETS.length;
applyBase(BASE_SETS[idx]);
}, 1600);
}
})();// CaseRelatedCta — vanilla 판(run/340/demo/07-case-related-cta/index.html) 의 React 포트.
// 카드마다 태그 목록을 갖고, 지금 보는 사례 태그와 겹치는 개수를 매번 직접 세어 관련도 점수로
// 삼는다. 점수 내림차순으로 다시 정렬하고 FLIP(이동 전 위치 기록 → 갱신 → 이동 후 위치와의
// 차이만큼 역보정 이동)으로 순서 변화를 transform 만으로 보여준다.
import { useEffect, useRef, useState } from "react";
import styles from "./CaseRelatedCta.module.scss";
export type CaseRelatedCtaProps = {
duration?: string;
easing?: string;
color?: string;
};
const HINT = "지금 보는 사례의 태그와 겹치는 개수를 직접 세어 점수를 매기고, 점수 순으로 카드를 다시 줄 세웁니다";
type Card = { id: string; name: string; tags: string[] };
const BASE_SETS: string[][] = [
["그로스", "온보딩", "B2B"],
["핀테크", "보안", "대시보드"],
["이커머스", "체크아웃", "모바일"]
];
const CARDS: Card[] = [
{ id: "nova", name: "노바 · 그로스 리포트", tags: ["그로스", "온보딩", "SaaS"] },
{ id: "atlas", name: "아틀라스 · B2B 영업", tags: ["B2B", "온보딩", "대시보드"] },
{ id: "cobalt", name: "코발트 · 결제 보안", tags: ["핀테크", "보안", "B2B"] },
{ id: "silverline", name: "실버라인 · 체크아웃", tags: ["이커머스", "체크아웃", "모바일"] },
{ id: "terra", name: "테라 · 모바일 대시보드", tags: ["대시보드", "모바일", "그로스"] }
];
function scoreFor(card: Card, baseTags: string[]): number {
let overlap = 0;
for (let i = 0; i < card.tags.length; i++) {
if (baseTags.indexOf(card.tags[i]) !== -1) overlap++;
}
return overlap;
}
export default function CaseRelatedCta({
duration = "600ms",
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#0a4fe8"
}: CaseRelatedCtaProps) {
const [baseIdx, setBaseIdx] = useState(0);
const elRefs = useRef<Record<string, HTMLElement | null>>({});
const rectsBeforeRef = useRef<Record<string, DOMRect>>({});
const reduceRef = useRef(false);
const baseTags = BASE_SETS[baseIdx];
const scored = CARDS.map((card) => ({ card, score: scoreFor(card, baseTags) })).sort(
(a, b) => b.score - a.score
);
useEffect(() => {
if (reduceRef.current) return;
const before = rectsBeforeRef.current!;
CARDS.forEach((card) => {
const el = elRefs.current![card.id];
if (!el) return;
const first = before[card.id];
if (!first) return;
const last = el.getBoundingClientRect();
const dx = first.left - last.left;
const dy = first.top - last.top;
if (dx === 0 && dy === 0) return;
el.style.transition = "none";
el.style.transform = "translate(" + dx + "px, " + dy + "px)";
el.getBoundingClientRect();
el.style.transition = "";
requestAnimationFrame(() => {
el.classList.add(styles.cardFlip);
el.style.transform = "";
});
const handler = () => {
el.classList.remove(styles.cardFlip);
el.removeEventListener("transitionend", handler);
};
el.addEventListener("transitionend", handler);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [baseIdx]);
useEffect(() => {
reduceRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduceRef.current) return;
function cycle(): void {
const rects: Record<string, DOMRect> = {};
CARDS.forEach((card) => {
const el = elRefs.current![card.id];
if (el) rects[card.id] = el.getBoundingClientRect();
});
rectsBeforeRef.current = rects;
setBaseIdx((i) => (i + 1) % BASE_SETS.length);
}
const timer = window.setInterval(cycle, 1600);
return () => window.clearInterval(timer);
}, []);
return (
<main className={styles.stage}>
<section className={styles.panel}>
<p className={styles.hint}>{HINT}</p>
<p className={styles.baseTagRow}>지금 사례 태그: <span>{baseTags.join(" · ")}</span></p>
<div className={styles.list} role="list" aria-label="관련 사례">
{scored.map(({ card, score }) => (
<article
key={card.id}
className={styles.card}
role="listitem"
ref={(el: HTMLElement | null) => {
elRefs.current![card.id] = el;
}}
>
<p className={styles.cardName}>{card.name}</p>
<p className={styles.cardScore}>
<span className={styles.cardScoreNum}>{score}</span>
<span className={styles.cardScoreLabel}>개 태그 겹침</span>
</p>
</article>
))}
</div>
</section>
</main>
);
}07 関連CTA — 共通タグ数から関連度を自分で求めるおすすめ — 計測 累積面積 18.349% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · 導入事例 web デザイン9選 — 成果数値まで計算して見せる
このデモが属する記事
導入事例 web デザイン9選 — 成果数値まで計算して見せる導入事例 web デザインは色や余白より先に、数字がどう動くかで差がつきます。画面に入った瞬間から成果数値がフレームごとに求め直されカウントアップし、事例ごとに計算したインパクトスコアでグリッドタイルの大きさが決まり、引用文の長さに合わせて この記事のデモをすべて見る


