04Social mention cards that roll in from the side
Each new mention rolls in from the side with translateX, tilts a touch with rotate, then settles upright, and the list keeps role="log" so fresh mentions stack on top.
Best forlive reactions beside a product page, a mention wall on an event screen
translateXrotaterole="log"

<main class="stage">
<section class="mn is-demo" id="mn" aria-label="Live mentions">
<header class="mn__h">
<span class="mn__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M128 24a104 104 0 0 0 0 208c21.51 0 44.1-6.48 60.43-17.33a8 8 0 0 0-8.86-13.33C166 210.38 146.21 216 128 216a88 88 0 1 1 88-88c0 26.45-10.88 32-20 32s-20-5.55-20-32V88a8 8 0 0 0-16 0v4.26a48 48 0 1 0 5.93 65.1c6 12 16.35 18.64 30.07 18.64c22.54 0 36-17.94 36-48A104.11 104.11 0 0 0 128 24m0 136a32 32 0 1 1 32-32a32 32 0 0 1-32 32"/></g></svg></span>
<h2 class="mn__t">Live mentions</h2>
<span class="mn__live"><span class="mn__pdot" aria-hidden="true"></span>live</span>
</header>
<ul class="mn__list" role="log" aria-live="polite" id="mn-list">
<li class="mn__row mn__row--new" data-row="new">
<div class="mn__fold"><div class="mn__card">
<p class="mn__txt">Put it in the sidebar and the team got it right away</p>
<p class="mn__who">@sora.codes <time class="mn__tm">now</time></p>
</div></div>
</li>
<li class="mn__row" data-row="1">
<div class="mn__fold"><div class="mn__card">
<p class="mn__txt">Can I copy this checkout screen as is</p>
<p class="mn__who">@yuna.builds <time class="mn__tm">2m ago</time></p>
</div></div>
</li>
<li class="mn__row mn__row--old" data-row="2">
<div class="mn__fold"><div class="mn__card">
<p class="mn__txt">This waiting-for-reply widget is really pretty</p>
<p class="mn__who">@minho.dev <time class="mn__tm">9m ago</time></p>
</div></div>
</li>
</ul>
<button class="mn__more" type="button" id="mn-more">Fetch mentions</button>
</section>
</main>
<template id="mn-tpl"><li class="mn__row is-in"><div class="mn__fold"><div class="mn__card">
<p class="mn__txt">Saved your activity feed article</p>
<p class="mn__who">@dali.works <time class="mn__tm">now</time></p>
</div></div></li></template>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-orange);
$card: $subject-cream;
$ink: $stage-orange;
.mn {
box-sizing: border-box;
width: min(304px, 92vw);
padding: $sp-3;
border: 1px solid rgba(255, 247, 230, .35);
border-radius: $r-card;
background: rgba(23, 20, 26, .08);
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
overflow: hidden; // 굴러 들어오는 카드가 무대에 삐져나오지 않게
}
.mn__h {
display: flex;
align-items: center;
gap: $sp-1;
margin: 0 0 $sp-2;
}
.mn__ic { display: inline-flex; width: 14px; height: 14px; color: $color; }
.mn__t { margin: 0; font-size: 12px; font-weight: 700; }
.mn__live {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: auto;
color: $subject-ink;
font-size: 10px;
font-weight: 700;
}
.mn__pdot {
width: 7px;
height: 7px;
border-radius: 50%;
background: $stage-yellow;
}
.mn__list {
display: grid;
gap: $sp-2;
margin: 0;
padding: 0;
list-style: none;
}
// ── 언급 카드 ─────────────────────────────────────
.mn__fold { display: grid; grid-template-rows: 1fr; }
.mn__card {
min-height: 0;
padding: $sp-2;
border-radius: $r-sm;
background: $card;
color: $subject-ink;
}
.mn__txt { margin: 0; font-size: 12px; font-weight: 700; line-height: 1.35; }
.mn__who {
display: flex;
align-items: center;
gap: $sp-1;
margin: $sp-1 0 0;
color: $subject-blue-deep;
font-size: 11px;
font-weight: 700;
}
.mn__tm { color: rgba(23, 20, 26, .72); font-size: 10px; }
.mn__row--new .mn__fold { grid-template-rows: 0fr; }
// 사람이 눌러 넣은 카드: 옆에서 굴러 들어와 살짝 틀어졌다 바로 선다 (is-in 동안만)
.mn__row.is-in .mn__card { animation: mn-roll $dur-enter $ease-pop both; }
@keyframes mn-roll {
from { transform: translateX(118%) rotate(6deg); }
60% { transform: translateX(-4%) rotate(-2deg); }
to { transform: translateX(0) rotate(0deg); }
}
.mn__more {
display: inline-flex;
align-items: center;
margin: $sp-2 0 0;
padding: $sp-1 $sp-3;
border: 1px solid rgba(255, 247, 230, .5);
border-radius: $r-pill;
background: transparent;
color: $subject-ink;
font-family: inherit;
font-size: 12px;
font-weight: 700;
line-height: 1.6;
cursor: pointer;
}
.mn__more:focus-visible { outline: 2px solid $stage-yellow; outline-offset: 2px; }
.mn__more:active { transform: scale(.94); }
// ── 격자 미리보기 전용 2초 루프 ─────────────────────
// 펼침은 컷으로 자르고, 카드는 옆에서 굴러 들어온다 — 퇴장은 재현하지 않는다
@keyframes mn-fold-in {
0%, 17% { grid-template-rows: 0fr; }
18% { grid-template-rows: 1fr; }
18.1%, 86% { grid-template-rows: 1fr; }
87%, 100% { grid-template-rows: 0fr; }
}
@keyframes mn-card-roll {
0%, 18% { transform: translateX(118%) rotate(6deg); }
30%, 86% { transform: translateX(0) rotate(0deg); }
87%, 100% { transform: translateX(118%) rotate(6deg); }
}
@keyframes mn-bump-1 {
0%, 18% { transform: translateY(0); }
23% { transform: translateY(3px); }
29%, 86% { transform: translateY(0); }
87%, 100% { transform: translateY(0); }
}
@keyframes mn-pdot {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.45); opacity: .6; }
}
@keyframes mn-btn-breath {
0%, 17% { transform: translateY(0); }
22% { transform: translateY(2px); }
27%, 86% { transform: translateY(0); }
87%, 100% { transform: translateY(0); }
}
.mn.is-demo {
.mn__row--new .mn__fold {
animation-name: mn-fold-in;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.mn__row--new .mn__card {
animation-name: mn-card-roll;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.mn__row[data-row="1"] .mn__card {
animation-name: mn-bump-1;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.mn__pdot {
animation-name: mn-pdot;
animation-duration: 700ms;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
.mn__more {
animation-name: mn-btn-breath;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
}
@media (max-width: 340px) {
.mn { padding: $sp-2; font-size: 11px; }
.mn__h { margin-bottom: $sp-1; }
.mn__card { padding: $sp-2; }
.mn__txt { font-size: 11px; }
.mn__row--old { display: none; } // 좁은 화면에서는 오래된 언급부터 비운다
.mn__more { margin-top: $sp-1; padding: 0 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.mn__row.is-in .mn__card { animation: none !important; }
.mn__more:active { transform: none; }
.mn.is-demo .mn__row--new .mn__fold,
.mn.is-demo .mn__row--new .mn__card,
.mn.is-demo .mn__row[data-row="1"] .mn__card,
.mn.is-demo .mn__pdot,
.mn.is-demo .mn__more { animation: none !important; transform: none !important; }
// 움직임만 끄고 결과 상태는 남긴다 — 새 언급은 펼쳐진 채
.mn.is-demo .mn__row--new .mn__fold { grid-template-rows: 1fr !important; }
}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);
}
.mn {
box-sizing: border-box;
width: min(304px, 92vw);
padding: 12px;
border: 1px solid rgba(255, 247, 230, 0.35);
border-radius: 16px;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 1.3;
overflow: hidden;
}
.mn__h {
display: flex;
align-items: center;
gap: 4px;
margin: 0 0 8px;
}
.mn__ic {
display: inline-flex;
width: 14px;
height: 14px;
color: #fff7e6;
}
.mn__t {
margin: 0;
font-size: 12px;
font-weight: 700;
}
.mn__live {
display: inline-flex;
align-items: center;
gap: 4px;
margin-left: auto;
color: #17141a;
font-size: 10px;
font-weight: 700;
}
.mn__pdot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #ffd23f;
}
.mn__list {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.mn__fold {
display: grid;
grid-template-rows: 1fr;
}
.mn__card {
min-height: 0;
padding: 8px;
border-radius: 8px;
background: #fff7e6;
color: #17141a;
}
.mn__txt {
margin: 0;
font-size: 12px;
font-weight: 700;
line-height: 1.35;
}
.mn__who {
display: flex;
align-items: center;
gap: 4px;
margin: 4px 0 0;
color: #0a4fe8;
font-size: 11px;
font-weight: 700;
}
.mn__tm {
color: rgba(23, 20, 26, 0.72);
font-size: 10px;
}
.mn__row--new .mn__fold {
grid-template-rows: 0fr;
}
.mn__row.is-in .mn__card {
animation: mn-roll 600ms cubic-bezier(0.2, 1.4, 0.4, 1) both;
}
@keyframes mn-roll {
from {
transform: translateX(118%) rotate(6deg);
}
60% {
transform: translateX(-4%) rotate(-2deg);
}
to {
transform: translateX(0) rotate(0deg);
}
}
.mn__more {
display: inline-flex;
align-items: center;
margin: 8px 0 0;
padding: 4px 12px;
border: 1px solid rgba(255, 247, 230, 0.5);
border-radius: 999px;
background: transparent;
color: #17141a;
font-family: inherit;
font-size: 12px;
font-weight: 700;
line-height: 1.6;
cursor: pointer;
}
.mn__more:focus-visible {
outline: 2px solid #ffd23f;
outline-offset: 2px;
}
.mn__more:active {
transform: scale(0.94);
}
@keyframes mn-fold-in {
0%, 17% {
grid-template-rows: 0fr;
}
18% {
grid-template-rows: 1fr;
}
18.1%, 86% {
grid-template-rows: 1fr;
}
87%, 100% {
grid-template-rows: 0fr;
}
}
@keyframes mn-card-roll {
0%, 18% {
transform: translateX(118%) rotate(6deg);
}
30%, 86% {
transform: translateX(0) rotate(0deg);
}
87%, 100% {
transform: translateX(118%) rotate(6deg);
}
}
@keyframes mn-bump-1 {
0%, 18% {
transform: translateY(0);
}
23% {
transform: translateY(3px);
}
29%, 86% {
transform: translateY(0);
}
87%, 100% {
transform: translateY(0);
}
}
@keyframes mn-pdot {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.45);
opacity: 0.6;
}
}
@keyframes mn-btn-breath {
0%, 17% {
transform: translateY(0);
}
22% {
transform: translateY(2px);
}
27%, 86% {
transform: translateY(0);
}
87%, 100% {
transform: translateY(0);
}
}
.mn.is-demo .mn__row--new .mn__fold {
animation-name: mn-fold-in;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.mn.is-demo .mn__row--new .mn__card {
animation-name: mn-card-roll;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.mn.is-demo .mn__row[data-row="1"] .mn__card {
animation-name: mn-bump-1;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.mn.is-demo .mn__pdot {
animation-name: mn-pdot;
animation-duration: 700ms;
animation-timing-function: ease-in-out;
animation-iteration-count: infinite;
}
.mn.is-demo .mn__more {
animation-name: mn-btn-breath;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.mn {
padding: 8px;
font-size: 11px;
}
.mn__h {
margin-bottom: 4px;
}
.mn__card {
padding: 8px;
}
.mn__txt {
font-size: 11px;
}
.mn__row--old {
display: none;
}
.mn__more {
margin-top: 4px;
padding: 0 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.mn__row.is-in .mn__card {
animation: none !important;
}
.mn__more:active {
transform: none;
}
.mn.is-demo .mn__row--new .mn__fold,
.mn.is-demo .mn__row--new .mn__card,
.mn.is-demo .mn__row[data-row="1"] .mn__card,
.mn.is-demo .mn__pdot,
.mn.is-demo .mn__more {
animation: none !important;
transform: none !important;
}
.mn.is-demo .mn__row--new .mn__fold {
grid-template-rows: 1fr !important;
}
}// 실제 동작: 언급 불러오기를 누르면 다음 언급이 카드로 굴러 들어와 맨 위에 놓인다 (등장만).
// 목록은 role="log" + aria-live="polite" — 새 언급이 낭독기에도 전해진다.
var mn = document.getElementById('mn');
var mnList = document.getElementById('mn-list');
document.getElementById('mn-more').addEventListener('click', function () {
mn.classList.remove('is-demo');
var row = document.getElementById('mn-tpl').content.firstElementChild.cloneNode(true);
mnList.insertBefore(row, mnList.firstChild);
row.addEventListener('animationend', function () { row.classList.remove('is-in'); }, { once: true });
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SocialMentionRoll.module.scss";
export type SocialMentionRollProps = {
heading?: string;
groupLabel?: string;
liveLabel?: string;
moreLabel?: string;
nowLabel?: string;
cards?: { key: string; handle: string; text: string; tm: string }[];
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
function AtIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" />
<path d="M128 24a104 104 0 0 0 0 208c21.51 0 44.1-6.48 60.43-17.33a8 8 0 0 0-8.86-13.33C166 210.38 146.21 216 128 216a88 88 0 1 1 88-88c0 26.45-10.88 32-20 32s-20-5.55-20-32V88a8 8 0 0 0-16 0v4.26a48 48 0 1 0 5.93 65.1c6 12 16.35 18.64 30.07 18.64c22.54 0 36-17.94 36-48A104.11 104.11 0 0 0 128 24m0 136a32 32 0 1 1 32-32a32 32 0 0 1-32 32" />
</g>
</svg>
);
}
const DEFAULT_CARDS = [
{ key: "m1", handle: "@yuna.builds", text: "Can I copy this checkout screen as is", tm: "2m ago" },
{ key: "m2", handle: "@minho.dev", text: "This waiting-for-reply widget is really pretty", tm: "9m ago" },
];
export default function SocialMentionRoll({
heading = "Live mentions",
groupLabel = "Live mentions",
liveLabel = "live",
moreLabel = "Fetch mentions",
nowLabel = "now",
cards = DEFAULT_CARDS,
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: SocialMentionRollProps) {
const [live, setLive] = useState(demo);
const [rows, setRows] = useState(cards);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function more() {
setLive(false);
setRows((r) => [{ key: String(Date.now()), handle: "@dali.works", text: "Saved your activity feed article", tm: nowLabel }, ...r]);
}
const cls = [styles.mn, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<header className={styles.h}>
<span className={styles.ic} aria-hidden="true"><AtIcon /></span>
<h2 className={styles.t}>{heading}</h2>
<span className={styles.liveTag}>
<span className={styles.pdot} aria-hidden="true"></span>
{liveLabel}
</span>
</header>
<ul className={styles.list} role="log" aria-live="polite">
<li className={styles.rowNew}>
<div className={styles.fold}>
<div className={styles.card}>
<p className={styles.txt}>Put it in the sidebar and the team got it right away</p>
<p className={styles.who}>@sora.codes <time className={styles.tm}>{nowLabel}</time></p>
</div>
</div>
</li>
{rows.map((r, i) => (
<li key={r.key} className={[styles.row, i === 0 ? styles.row1 : "", i === rows.length - 1 ? styles.rowOld : ""].filter(Boolean).join(" ")}>
<div className={styles.fold}>
<div className={i === 0 ? styles.cardIn : styles.card}>
<p className={styles.txt}>{r.text}</p>
<p className={styles.who}>{r.handle} <time className={styles.tm}>{r.tm}</time></p>
</div>
</div>
</li>
))}
</ul>
<button className={styles.more} type="button" onClick={more}>{moreLabel}</button>
</section>
);
}04 Social mention cards that roll in from the side — Measured area 30.385% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · 9 Activity Feed UI Widgets — Sidebar-Ready Lists
The post this demo lives in
9 Activity Feed UI Widgets — Sidebar-Ready ListsAn activity feed ui is a list that keeps stacking in a screen corner, not a toast that flashes and fades. These nine Sid See all demos in this post
If you like this effect - similar demos
Embeds02 Tabs that swap the same example per language9 Code Block UI Patterns — No Highlighter Library
Embeds07 Precipitation bars that grow, and a compass that points into the windWeather Widget Design — 9 Widgets, No Library
Embeds01 YouTube responsive embed — a video frame that recomputes its ratio from the available width9 YouTube Embed Code UI Patterns (Copy-Paste)