独家
Blocked Blur <mv-blocked-blur>
保留对话结构的屏蔽方式。
文化出处
黑镜,“白色圣诞节”,查理·布鲁克(Channel 4)(2014 年,剧集)。人们可以在现实生活中互相屏蔽:被屏蔽的人会变成一个灰色的模糊轮廓,声音变成含混的噪音,人仍在那里,却无法辨认。在 UI 中,被屏蔽或被过滤的消息在讨论串中保留原有的位置和大小,显示为平静的轮廓,其内容被收存起来,因此周围的回复依然说得通,直到用户选择显示一次或取消屏蔽。
工作原理
保留对话结构的屏蔽方式。包裹一个评论串、信息流或聊天:来自被屏蔽作者的条目(data-author 与屏蔽 ID 列表或 isMuted 回调匹配)会变成平静的轮廓,而不是直接消失,因此周围的回复依然说得通。头像变成中性的模糊人形,名字显示为“Muted user”,文字变成相同长度的柔和条块,条目保持完全相同的尺寸;一个标签提供“Show once”和“Unmute”。可读的节点会被移出 DOM 并保存在内存中,而不只是用 CSS 模糊处理,因此在用户请求之前,DOM 和无障碍树中都不会留下任何内容。“Show once”(可取消的 mv-reveal)会就地恢复内容,当内容滚出视口或在可配置的计时器结束后会再次隐藏,计时器在悬停或聚焦时暂停。引用了被屏蔽消息的回复,其引用部分同样显示为轮廓(data-quote-author 或 data-quote-of)。同一机制也处理内容过滤,并附带原因标签:屏蔽的关键词(“Hidden: contains a keyword you muted”)、spoiler 或 sensitive 等条目标记,或自定义过滤回调。三种变体:silhouette(保留布局)、compact(每段连续内容显示一行“2 messages from a muted user”)和 hidden(移除,留下讨论串缺口标记)。
| 分类 | 数据展示 |
|---|---|
| 类型 | Web Component(<mv-blocked-blur>) |
| 状态 | 稳定版 |
| 套件 | 信任与隐私 |
| Keywords | exclusive, culture, mute, block, blocked-user, muted-user, moderation, trust-and-safety, spoiler, sensitive-content, content-warning, keyword-filter, comments, thread, chat, feed, silhouette, placeholder, reveal |
When to use
- A forum, comment section or chat lets users mute or block people, and replies to their messages must still make sense
- Spoilers, sensitive posts or muted keywords must be hidden until the user asks to see them, with the reason shown
- A moderation or trust-and-safety feature needs content kept out of the page and the accessibility tree until an explicit reveal
- A feed must keep its layout and scroll position stable while people are muted and unmuted
Avoid when
- The content is a secret meant to be shown once and then destroyed for good → use Self-Destruct instead
- The placeholders stand for content that is still loading, not content that is hidden on purpose → use Skeleton instead
- The server can simply leave muted content out and nobody needs to see that it exists
安装
node scripts/add.mjs blocked-blur --out ./src/marvelous通过 Marvelous UI MCP 服务器使用 AI 智能体:install_components({ slugs: ["blocked-blur"], target_dir: "<absolute path>/src/marvelous", framework: "react" })。
复制的文件(含依赖):tokens/tokens.css、core/base.css、core/dom.js、core/element.js、core/motion.js、components/blocked-blur/blocked-blur.js、components/blocked-blur/blocked-blur.css。
用法
快速开始,最小可运行标记:
<mv-blocked-blur muted="u_rick">
<p data-author="u_kenji">Kenji: use night mode and a tripod.</p>
<p data-author="u_rick">Rick: phone shots all look fake.</p>
</mv-blocked-blur>标准标记,可在此基础上通过属性、data-* 和 CSS 变量进行定制:
<div id="bb-demo" style="width:min(100%,68rem);margin-inline:auto">
<style>
#bb-demo { font-size: .875rem }
#bb-demo .bbd-bar { display:flex; flex-wrap:wrap; align-items:center; gap:.75rem 1.5rem; margin:0 0 1rem; padding:.75rem 1rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface) }
#bb-demo .bbd-field { display:flex; align-items:center; gap:.625rem }
#bb-demo .bbd-k { color:var(--mv-fg-muted); font-size:.75rem; font-weight:600 }
#bb-demo .bbd-list { display:flex; flex-wrap:wrap; gap:.375rem; align-items:center }
#bb-demo .bbd-list .mv-badge { gap:.3rem }
#bb-demo .bbd-list .mv-badge svg { width:.75rem; height:.75rem; opacity:.7 }
#bb-demo .bbd-none { color:var(--mv-fg-subtle); font-size:.75rem }
#bb-demo .bbd-log { flex-basis:100%; margin:0; min-height:1.1rem; color:var(--mv-fg-muted); font:500 .75rem/1.4 var(--mv-font-mono) }
#bb-demo .bbd-log b { color:var(--mv-fg); font-weight:600 }
#bb-demo .bbd-grid { display:grid; grid-template-columns:minmax(0,1fr) 21rem; gap:1.25rem; align-items:start }
#bb-demo .bbd-card { border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); overflow:hidden }
#bb-demo .bbd-head { padding:1rem 1.25rem .875rem; border-bottom:1px solid var(--mv-border) }
#bb-demo .bbd-head small { display:block; color:var(--mv-fg-muted); font-size:.75rem }
#bb-demo .bbd-head h3 { margin:.25rem 0 0; font-size:1rem; letter-spacing:-.01em; line-height:1.35 }
#bb-demo .bbd-thread { padding:.5rem 1.25rem 1rem }
/* Comments */
#bb-demo .bbd-c { display:grid; grid-template-columns:2rem minmax(0,1fr); gap:0 .75rem; padding-top:.875rem }
#bb-demo .bbd-c > .mv-avatar { grid-row:span 2 }
#bb-demo .bbd-c-main { min-width:0 }
#bb-demo .bbd-c-head { display:flex; align-items:baseline; gap:.5rem; line-height:1.5 }
#bb-demo .bbd-c-head b { font-weight:600; font-size:.8125rem }
#bb-demo .bbd-c-head time { color:var(--mv-fg-subtle); font-size:.75rem }
#bb-demo .bbd-c p { margin:.125rem 0 0; line-height:1.55; color:var(--mv-fg) }
#bb-demo .bbd-c blockquote { margin:.375rem 0 .25rem; padding:.375rem .75rem; border-inline-start:3px solid var(--mv-border-strong); border-radius:0 var(--mv-radius-md) var(--mv-radius-md) 0; background:var(--mv-bg-subtle); color:var(--mv-fg-muted); font-size:.8125rem; line-height:1.5 }
#bb-demo .bbd-foot { display:flex; align-items:center; gap:.25rem; margin:.25rem 0 0 -.5rem; min-height:1.75rem }
#bb-demo .bbd-act { display:inline-flex; align-items:center; gap:.3rem; height:1.625rem; padding:0 .5rem; border:0; border-radius:var(--mv-radius-md); background:none; color:var(--mv-fg-muted); font:600 .75rem var(--mv-font-sans); cursor:pointer }
#bb-demo .bbd-act:hover { background:var(--mv-bg-muted); color:var(--mv-fg) }
#bb-demo .bbd-act:focus-visible { outline:none; box-shadow:var(--mv-focus-ring) }
#bb-demo .bbd-act svg { width:.875rem; height:.875rem }
#bb-demo .bbd-replies { margin-inline-start:-1.625rem; padding-inline-start:1.625rem; border-inline-start:2px solid var(--mv-border) }
/* Chat */
#bb-demo .bbd-chat { display:grid; gap:.625rem; padding:1rem }
#bb-demo .bbd-msg { display:flex; align-items:flex-end; gap:.5rem; max-width:88% }
#bb-demo .bbd-bubble { min-width:0; padding:.5rem .75rem; border-radius:1rem 1rem 1rem .375rem; background:var(--mv-bg-muted) }
#bb-demo .bbd-bubble b { display:block; font-size:.6875rem; font-weight:600; color:var(--mv-fg-muted); line-height:1.4 }
#bb-demo .bbd-bubble p { margin:0; line-height:1.45 }
#bb-demo .bbd-msg[data-me] { margin-inline-start:auto; flex-direction:row-reverse }
#bb-demo .bbd-msg[data-me] .bbd-bubble { border-radius:1rem 1rem .375rem 1rem; background:var(--mv-accent); color:var(--mv-fg-on-accent) }
#bb-demo .bbd-day { justify-self:center; color:var(--mv-fg-subtle); font-size:.6875rem; font-weight:600; letter-spacing:.04em; text-transform:uppercase }
#bb-demo .bbd-compose { display:flex; align-items:center; gap:.5rem; padding:.75rem 1rem; border-top:1px solid var(--mv-border); color:var(--mv-fg-subtle); font-size:.8125rem }
#bb-demo .bbd-compose span { flex:1; padding:.5rem .75rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-full) }
@media (max-width:56rem) { #bb-demo .bbd-grid { grid-template-columns:minmax(0,1fr) } }
</style>
<div class="bbd-bar">
<div class="bbd-field">
<span class="bbd-k" id="bbd-l-variant">Thread layout</span>
<mv-segmented id="bbd-variant" aria-labelledby="bbd-l-variant" name="variant" value="silhouette">
<button value="silhouette">Silhouette</button>
<button value="compact">Compact</button>
<button value="hidden">Hidden</button>
</mv-segmented>
</div>
<div class="bbd-field">
<span class="bbd-k">Muted</span>
<span class="bbd-list" id="bbd-muted"></span>
</div>
<div class="bbd-field">
<span class="bbd-k">Keyword</span>
<span class="mv-badge" data-variant="outline" data-shape="pill">giveaway</span>
</div>
<label class="mv-choice" data-control="end">
<input type="checkbox" role="switch" class="mv-switch" id="bbd-timer">
<span class="mv-choice-text"><span class="mv-choice-title">Re-hide after 15 s</span></span>
</label>
<p class="bbd-log" id="bbd-log" aria-hidden="true">Rick Vance and Marcus Hill are muted. Their messages keep their place in the conversation.</p>
</div>
<div class="bbd-grid">
<section class="bbd-card" aria-labelledby="bbd-post">
<header class="bbd-head">
<small>Night Sky Photography · posted by Ingrid Solberg · 142 comments</small>
<h3 id="bbd-post">Phone or real camera for the northern lights? First trip to Tromsø in February</h3>
</header>
<mv-blocked-blur id="bbd-thread" class="bbd-thread" muted="u_rick u_marcus" keywords="giveaway">
<article class="bbd-c" data-author="u_kenji" id="c-kenji">
<span class="mv-avatar" data-size="sm" data-variant="tinted" style="--mv-avatar-hue:250" data-mute-part="avatar" role="img" aria-label="Kenji Watanabe"><span class="mv-avatar-fallback" aria-hidden="true">KW</span></span>
<div class="bbd-c-main">
<div class="bbd-c-head"><b data-mute-part="name">Kenji Watanabe</b><time datetime="2026-09-23T08:10">3h</time></div>
<p data-mute-part="body">A recent phone is enough if it sits on a tripod. Use night mode, set the exposure to 3-5 seconds and turn the flash off. A stable base matters more than the sensor.</p>
<div class="bbd-foot" data-mute-part="actions"><button class="bbd-act" type="button">Reply</button><button class="bbd-act" type="button" data-mute="u_kenji">Mute</button></div>
<div class="bbd-replies">
<article class="bbd-c" data-author="u_rick" id="c-rick">
<span class="mv-avatar" data-size="sm" data-variant="tinted" style="--mv-avatar-hue:20" data-mute-part="avatar" role="img" aria-label="Rick Vance"><span class="mv-avatar-fallback" aria-hidden="true">RV</span></span>
<div class="bbd-c-main">
<div class="bbd-c-head"><b data-mute-part="name">Rick Vance</b><time datetime="2026-09-23T08:42">3h</time></div>
<p data-mute-part="body">Phone aurora shots all look fake. If you can’t afford a real camera, stay home and watch a video of it instead.</p>
<div class="bbd-foot" data-mute-part="actions"><button class="bbd-act" type="button">Reply</button><button class="bbd-act" type="button" data-mute="u_rick">Mute</button></div>
<div class="bbd-replies">
<article class="bbd-c" data-author="u_amara" id="c-amara">
<span class="mv-avatar" data-size="sm" data-variant="tinted" style="--mv-avatar-hue:150" data-mute-part="avatar" role="img" aria-label="Amara Okafor"><span class="mv-avatar-fallback" aria-hidden="true">AO</span></span>
<div class="bbd-c-main">
<div class="bbd-c-head"><b data-mute-part="name">Amara Okafor</b><time datetime="2026-09-23T09:05">2h</time></div>
<blockquote data-quote-author="u_rick">If you can’t afford a real camera, stay home and watch a video of it instead.</blockquote>
<p data-mute-part="body">Hard disagree. My best shot from Abisko was taken on a phone with a $20 tripod. Kenji’s settings are exactly right.</p>
<div class="bbd-foot" data-mute-part="actions"><button class="bbd-act" type="button">Reply</button><button class="bbd-act" type="button" data-mute="u_amara">Mute</button></div>
</div>
</article>
</div>
</div>
</article>
<article class="bbd-c" data-author="u_ingrid" id="c-ingrid">
<span class="mv-avatar" data-size="sm" data-variant="tinted" style="--mv-avatar-hue:320" data-mute-part="avatar" role="img" aria-label="Ingrid Solberg"><span class="mv-avatar-fallback" aria-hidden="true">IS</span></span>
<div class="bbd-c-main">
<div class="bbd-c-head"><b data-mute-part="name">Ingrid Solberg</b><time datetime="2026-09-23T09:30">2h</time></div>
<p data-mute-part="body">Thanks Kenji, ordering a mini tripod today. Any tips for keeping the battery alive at −15 °C?</p>
<div class="bbd-foot" data-mute-part="actions"><button class="bbd-act" type="button">Reply</button><button class="bbd-act" type="button" data-mute="u_ingrid">Mute</button></div>
</div>
</article>
</div>
</div>
</article>
<article class="bbd-c" data-author="u_lucas" id="c-lucas">
<span class="mv-avatar" data-size="sm" data-variant="tinted" style="--mv-avatar-hue:60" data-mute-part="avatar" role="img" aria-label="Lucas Moreau"><span class="mv-avatar-fallback" aria-hidden="true">LM</span></span>
<div class="bbd-c-main">
<div class="bbd-c-head"><b data-mute-part="name">Lucas Moreau</b><time datetime="2026-09-23T10:02">1h</time></div>
<p data-mute-part="body">Giveaway! The first 50 people who message me get my aurora presets pack for free. Link in my profile.</p>
<div class="bbd-foot" data-mute-part="actions"><button class="bbd-act" type="button">Reply</button><button class="bbd-act" type="button" data-mute="u_lucas">Mute</button></div>
</div>
</article>
<article class="bbd-c" data-author="u_priya" id="c-priya">
<span class="mv-avatar" data-size="sm" data-variant="tinted" style="--mv-avatar-hue:200" data-mute-part="avatar" role="img" aria-label="Priya Raman"><span class="mv-avatar-fallback" aria-hidden="true">PR</span></span>
<div class="bbd-c-main">
<div class="bbd-c-head"><b data-mute-part="name">Priya Raman</b><time datetime="2026-09-23T10:40">48m</time></div>
<p data-mute-part="body">Watch cloud cover more than the activity forecast. Clear skies at a Kp of 2 beat a Kp of 5 behind clouds every time.</p>
<div class="bbd-foot" data-mute-part="actions"><button class="bbd-act" type="button">Reply</button><button class="bbd-act" type="button" data-mute="u_priya">Mute</button></div>
</div>
</article>
</mv-blocked-blur>
</section>
<section class="bbd-card" aria-labelledby="bbd-chat-title">
<header class="bbd-head">
<small>Group chat · 6 members</small>
<h3 id="bbd-chat-title">Book club · chapter 12</h3>
</header>
<mv-blocked-blur id="bbd-chat" class="bbd-chat" muted="u_rick u_marcus" variant="compact" flags="spoiler">
<span class="bbd-day">Today</span>
<div class="bbd-msg" data-author="u_sofia">
<span class="mv-avatar" data-size="xs" data-variant="tinted" style="--mv-avatar-hue:280" data-mute-part="avatar" role="img" aria-label="Sofia Almeida"><span class="mv-avatar-fallback" aria-hidden="true">SA</span></span>
<div class="bbd-bubble"><b data-mute-part="name">Sofia Almeida</b><p data-mute-part="body">Did everyone finish chapter 12?</p></div>
</div>
<div class="bbd-msg" data-author="u_marcus">
<span class="mv-avatar" data-size="xs" data-variant="tinted" style="--mv-avatar-hue:30" data-mute-part="avatar" role="img" aria-label="Marcus Hill"><span class="mv-avatar-fallback" aria-hidden="true">MH</span></span>
<div class="bbd-bubble"><b data-mute-part="name">Marcus Hill</b><p data-mute-part="body">This book is so overrated.</p></div>
</div>
<div class="bbd-msg" data-author="u_marcus">
<span class="mv-avatar" data-size="xs" data-variant="tinted" style="--mv-avatar-hue:30" data-mute-part="avatar" role="img" aria-label="Marcus Hill"><span class="mv-avatar-fallback" aria-hidden="true">MH</span></span>
<div class="bbd-bubble"><b data-mute-part="name">Marcus Hill</b><p data-mute-part="body">Whoever picked it clearly has no taste.</p></div>
</div>
<div class="bbd-msg" data-author="u_maya" data-flags="spoiler">
<span class="mv-avatar" data-size="xs" data-variant="tinted" style="--mv-avatar-hue:170" data-mute-part="avatar" role="img" aria-label="Maya Cohen"><span class="mv-avatar-fallback" aria-hidden="true">MC</span></span>
<div class="bbd-bubble"><b data-mute-part="name">Maya Cohen</b><p data-mute-part="body">I can’t believe the sister was the narrator all along.</p></div>
</div>
<div class="bbd-msg" data-author="u_me" data-me>
<div class="bbd-bubble"><p data-mute-part="body">No spoilers please, I’m 30 pages behind!</p></div>
</div>
<div class="bbd-msg" data-author="u_sofia">
<span class="mv-avatar" data-size="xs" data-variant="tinted" style="--mv-avatar-hue:280" data-mute-part="avatar" role="img" aria-label="Sofia Almeida"><span class="mv-avatar-fallback" aria-hidden="true">SA</span></span>
<div class="bbd-bubble"><b data-mute-part="name">Sofia Almeida</b><p data-mute-part="body">Let’s meet Thursday at 7 PM at the café on Elm Street.</p></div>
</div>
</mv-blocked-blur>
<div class="bbd-compose" aria-hidden="true"><span>Message the group…</span></div>
</section>
</div>
<script type="module">
const root = document.getElementById("bb-demo");
const thread = document.getElementById("bbd-thread");
const chat = document.getElementById("bbd-chat");
await customElements.whenDefined("mv-blocked-blur");
const names = { u_rick: "Rick Vance", u_marcus: "Marcus Hill", u_kenji: "Kenji Watanabe", u_amara: "Amara Okafor", u_ingrid: "Ingrid Solberg", u_lucas: "Lucas Moreau", u_priya: "Priya Raman" };
const both = [thread, chat];
const list = document.getElementById("bbd-muted");
const log = document.getElementById("bbd-log");
const renderMuted = () => {
const ids = [...new Set(both.flatMap((el) => el.muted))];
if (!ids.length) {
list.replaceChildren(Object.assign(document.createElement("span"), { className: "bbd-none", textContent: "Nobody" }));
return;
}
list.replaceChildren(...ids.map((id) => {
const b = document.createElement("button");
b.type = "button";
b.className = "mv-badge";
b.dataset.variant = "secondary";
b.dataset.shape = "pill";
b.setAttribute("aria-label", `Unmute ${names[id] ?? id}`);
b.append(names[id] ?? id);
const x = document.createElementNS("http://www.w3.org/2000/svg", "svg");
x.setAttribute("viewBox", "0 0 24 24"); x.setAttribute("fill", "none"); x.setAttribute("stroke", "currentColor"); x.setAttribute("stroke-width", "2.4"); x.setAttribute("stroke-linecap", "round"); x.setAttribute("aria-hidden", "true");
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", "M6 6l12 12M18 6 6 18");
x.append(path);
b.append(x);
b.addEventListener("click", () => thread.unmute(id) || chat.unmute(id));
return b;
}));
};
renderMuted();
const say = (event, text) => {
const b = document.createElement("b");
b.textContent = event;
log.replaceChildren(b, ` · ${text}`);
};
const who = (d) => (d.author ? names[d.author] ?? d.author : d.reason === "flag" ? "a spoiler" : d.reason === "keyword" ? "a muted keyword" : "a filter");
// One mute list for the whole app: muting in one place mutes everywhere.
root.addEventListener("mv-mute", (e) => {
const { author, items } = e.detail;
say("mv-mute", `${names[author] ?? author} · ${items.length} message${items.length === 1 ? "" : "s"} silhouetted`);
queueMicrotask(() => { both.forEach((el) => el.mute(author)); renderMuted(); });
});
root.addEventListener("mv-unmute", (e) => {
const { author } = e.detail;
say("mv-unmute", `${names[author] ?? author} is readable again`);
queueMicrotask(() => { both.forEach((el) => el.unmute(author)); renderMuted(); });
});
root.addEventListener("mv-reveal", (e) => say("mv-reveal", `${e.detail.items.length} message${e.detail.items.length === 1 ? "" : "s"} from ${who(e.detail)}, shown once (${e.detail.trigger})`));
root.addEventListener("mv-hide", (e) => say("mv-hide", `hidden again (${e.detail.cause})`));
thread.addEventListener("click", (e) => {
const btn = e.target.closest("[data-mute]");
if (btn) thread.mute(btn.dataset.mute);
});
document.getElementById("bbd-variant").addEventListener("mv-change", (e) => { thread.variant = e.detail?.value ?? e.target.value; });
document.getElementById("bbd-timer").addEventListener("change", (e) => {
both.forEach((el) => { el.rehide = e.target.checked ? "scroll 15s" : "scroll"; });
});
</script>
</div>API
Attributes
| Name | 类型 | Default | Description |
|---|---|---|---|
muted | string (ids separated by spaces or commas) | Muted author ids, matched against each item’s data-author. Kept in sync by mute() / unmute() when the attribute is present. The muted property accepts an array or a Set instead. | |
variant | silhouette | compact | hidden | silhouette | silhouette: each item keeps its size, with a neutral avatar, “Muted user” and bars of text. compact: consecutive hidden items (sibling items with no nested replies) collapse into one row, “2 messages from a muted user · Show once · Unmute”. hidden: the items are removed from view and leave a small “2 messages hidden” thread-gap marker. In compact and hidden, an item that has nested replies keeps its box and shows the row inside it, so the replies stay attached. |
keywords | string (comma-separated phrases) | Muted keywords, matched on whole words in the item’s content (body and media parts, or all of its own text), ignoring case and accents. Matching items are hidden with the reason “Hidden: contains a keyword you muted”; their author stays visible. | |
flags | string (comma-separated) | Item flags to hide, matched against each item’s data-flags (e.g. flags="spoiler, sensitive"). Reason label: “Hidden: marked as spoiler”. | |
rehide | "scroll" | time | "scroll <time>" | "never" | scroll | When a “Show once” reveal ends: scroll = once its content has been scrolled completely out of view; a time ("20s", "2m", "1500ms") = after that delay, paused while the content is hovered or has focus, with a thin draining line; both can be combined. Never while focus is inside the revealed content. Escape inside revealed content also hides it again. |
item | CSS selector | [data-author] | Which descendants are items. Items can nest (replies inside comments). |
quote | CSS selector | [data-quote-author], [data-quote-of] | Quotes of other messages. A quote is hidden as its own small silhouette when data-quote-author is muted, or when the item whose id is in data-quote-of is hidden for any reason (the quote takes the same reason). |
hide-unmute | boolean | Removes the Unmute button (e.g. when unmuting only happens in the user’s settings). | |
data-author | string | On each item: the author id. | |
data-mute-part | avatar | name | body | media | <any other> | On elements inside an item: what to hide and how. avatar → neutral blurred figure of the same size; name → “Muted user”; body (the default for an empty value) → bars of the same length and height; media → a soft block of the same size; any other value (actions, meta…) → blank space of the same size. The part element itself stays (with its classes and box); only its children and its aria-label, title, alt, href and role are stored away. Replaced elements (img, video, svg…) are swapped for a placeholder. Content filters (keywords, flags, filter) only hide body, media and quotes: the author stays visible. Without any part, all of the item’s own content (everything but nested items) becomes one silhouette. | |
data-flags | string | On an item or a quote: flags matched by the flags attribute. | |
data-blocked-blur | "hidden" | "revealed" | Set by the component on hidden and revealed items and quotes (with data-blocked-blur-reason: author | keyword | flag | filter), for styling. |
Properties
| Name | 类型 | Description |
|---|---|---|
muted | string[] | Muted author ids. Set an array, a Set or a string. |
isMuted | (item, authorId) => boolean | Optional callback OR-ed with the muted ids (for a store or an API-driven block list). When it drives the mute, update your source in mv-unmute. |
filter | (item) => null | string | { reason, key? } | Optional custom content filter. A string is the reason shown after “Hidden: ” (e.g. "reported by 3 people"). |
strings | Partial<Record<string, string>> | Overrides for every visible text and announcement (e.g. { mutedUser: "Blocked user", authorSr: "Message from a blocked user, hidden.", fromMuted: "from a blocked user", unmute: "Unblock" }). Keys: mutedUser, showOnce, unmute, hide, hideLabel, hideManyLabel, shownOnce, fromMuted, fromMutedMany, keyword, flag ({flag}), filter, itemLabel, itemSr, authorSr, quoteAuthorLabel, quoteAuthorSr, quoteLabel, quoteSr, compactAuthor, compact, compactSr, gap, gapShown, gapSr, message, messages, announceShown, announceShownTimed, announceShownPlain, announceHidden, announceMuted, announceUnmuted. English defaults. |
hiddenItems | Element[] | Items currently hidden (read-only; revealed items and quotes excluded). |
variant / item / quote / keywords / flags / rehide / hideUnmute | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
mute(authorId) | Emits the cancelable mv-mute, then hides every item from that author. If focus was inside one of them (the app’s own Mute button), it moves to the Unmute button of its silhouette, which works as an undo. Returns false if cancelled or already muted. |
unmute(authorId) | Emits the cancelable mv-unmute, then restores every item from that author for good. |
reveal(itemOrId) | Shows a hidden item or quote once, as the Show once button does (emits mv-reveal). |
hide(itemOrId?) | Hides a revealed item again, or every revealed item without argument (emits mv-hide with cause "api"). |
refresh() | Rebuilds every silhouette, re-measuring the content (after a font or layout change). |
Events
| Name | Description |
|---|---|
mv-reveal | Cancelable, before a Show once. detail: { items, author (null for content filters), authors, reason: "author" | "keyword" | "flag" | "filter", reasons, trigger: "button" | "api" }. preventDefault() keeps it hidden (e.g. an age check first). |
mv-hide | A revealed item was hidden again. detail: { items, cause: "scroll" | "timer" | "escape" | "button" | "api" }. |
mv-unmute | Cancelable, before an unmute from the chip or unmute(). detail: { author, items, trigger }. Persist the change here, or preventDefault() and handle it yourself (a confirmation, a settings page). |
mv-mute | Cancelable, before mute(). detail: { author, items }. |
Content structure
| Name | Description |
|---|---|
(content) | Any thread, list, feed or chat markup. Items are found anywhere inside (the item selector), including items added later: new messages, infinite scroll, edits and data-author / data-flags changes are picked up by a MutationObserver. |
CSS classes
| Name | Description |
|---|---|
mv-blocked-blur-veil / -lines | Body silhouette: a grid stacking the blurred bars (one <i> per line, --_lw width) and the notice chip; keeps the measured width and height of the text. |
mv-blocked-blur-avatar / -figure / -name / -media / -blank | Placeholders for the avatar, name, media and other parts. |
mv-blocked-blur-notice | The chip: -icon, -label (short visible label), a visually hidden sentence, -actions with .mv-blocked-blur-action buttons (data-mv-bb-action="show | unmute | hide"). |
mv-blocked-blur-row | Compact row or thread-gap marker (data-variant="compact | hidden", data-state="hidden | revealed", data-reason). Rendered as an <li> inside lists. |
mv-blocked-blur-pill / -drain | “Shown once · Hide” pill in the corner of a revealed silhouette, and the line that drains until the automatic re-hide (paused with data-paused). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-blocked-blur-tone | color-mix(fg 7%, surface) | Background of the avatar and media placeholders. |
--mv-blocked-blur-line | color-mix(fg 14%, transparent) | Color of the text bars. |
--mv-blocked-blur-figure | color-mix(fg 20%, transparent) | Color of the blurred figure in the avatar placeholder. |
--mv-blocked-blur-blur | 1.5px | Softness of the bars and the figure. |
--mv-blocked-blur-action | var(--mv-accent) | Color of the Show once button and of the draining line. |
--mv-blocked-blur-pill-top / --mv-blocked-blur-pill-end | var(--mv-space-2) | Position of the “Shown once” pill in the item’s corner (move it clear of your own corner menu). |
Accessibility
Hidden content is really gone: its nodes are detached and kept in memory, and the aria-label, aria-labelledby, aria-describedby, title, alt, href and role of the item and its parts are stored away too, so a screen reader, find-in-page, copy or an extension can’t read what the user chose not to see. All the placeholder shapes (bars, figure, “Muted user”) are aria-hidden; each hidden item exposes one visually hidden sentence, “Message from a muted user, hidden.” (or the filter reason), followed by real “Show once” and “Unmute” buttons described by that sentence. Compact rows and gap markers do the same (“2 messages from a muted user, hidden.”). Show once restores the content where the silhouette was: in the silhouette variant the item keeps its measured size, so nothing around it moves; focus moves to the “Hide” button of the revealed item, and a polite status announces “Shown once. It hides again when you scroll away.” Re-hiding never happens while focus is inside the revealed content or while it is hovered (the timer pauses); Escape hides it again and puts focus back on Show once. Muting with the app’s own button inside an item moves focus to that silhouette’s Unmute button (an immediate undo); unmuting focuses the restored item. Mute, unmute and timer re-hides are announced politely. Keyboard: every control is a native <button> with a visible focus ring. Reduced motion (OS or data-motion="reduce"): the blur-in on reveal and the fade on hide become instant, and the draining timer line is not drawn (the timer still runs). Forced colors: bars and figures use GrayText, chips and rows get a CanvasText border, buttons use LinkText.