익스클루시브
Curtain <mv-curtain>
잘 다듬어진 모든 수치에 “How is this calculated?”를 더합니다. 차트, KPI, 점수, 가격, AI 답변을 감싸면 손잡이가 커튼처럼 화면을 옆으로 걷어(부드러운 주름 그림자가 있는 클립 와이프, 동작 줄이기 설정에서는 단순한 크로스페이드) 같은 자리에 무대 뒤를 드러내며, 레이아웃이 튀지 않습니다.
문화적 레퍼런스
오즈의 마법사, 빅터 플레밍(MGM, L. 프랭크 바움의 1900년 소설 원작)(1939년, 영화). 작은 개가 커튼을 걷어 내자, 위대하고 강력한 마법사가 실은 레버를 조작하는 평범한 남자라는 사실이 드러납니다. 인상적인 쇼 뒤에는 단순하고 들여다볼 수 있는 장치가 있었던 것입니다. UI에서는 잘 다듬어진 어떤 수치든 그 자리에서 걷어 내어, 데이터, 수식, 출처, 최신성, 필터 등 그것이 어떻게 만들어졌는지 보여 줄 수 있습니다.
작동 방식
잘 다듬어진 모든 수치에 “How is this calculated?”를 더합니다. 차트, KPI, 점수, 가격 또는 AI 답변을 감싸면 손잡이가 시각 요소를 커튼처럼 옆으로 걷어 내어(부드러운 접힘 그림자가 있는 클립 와이프, 동작 줄이기 설정에서는 단순한 크로스페이드) 같은 영역 안에서 레이아웃 이동 없이 그 이면을 보여 줍니다. 탭은 제공한 내용만 보여 줍니다. Data(data 속성, JSON 스크립트 또는 슬롯에 넣은 <table>로 만드는 접근성 있는 정렬 가능 표로, 통화, 퍼센트, 숫자, 날짜를 Intl로 서식화하며, 스프레드시트 수식 주입에 안전한 RFC 4180 CSV 내보내기 포함), Method(복사 버튼이 있는 코드 블록 안의 수식 또는 쿼리, 그리고 풍부한 설명), Sources(링크, 세부 정보, 상대 및 절대 업데이트 시각, “data as of” 줄, stale-after를 넘기면 표시되는 오래됨 경고), Filters입니다. 시각 요소 아래의 레일은 최신성을 늘 보이게 하고(“Updated 2 hours ago · Billing ledger +2”), corner 변형은 작은 KPI 타일에 맞으며 오래된 데이터를 점으로 표시합니다. mv-reveal은 취소 가능하며, waitUntil(promise)로 이면을 지연 로드합니다(스켈레톤, 오류, 재시도 포함). 닫혀 있는 동안에는 차트의 접근성 있는 대체 콘텐츠 역할도 합니다. 자동 생성된 요약(범위, 최댓값과 최솟값, 최신 값)이 aria-describedby로 연결되고, 표의 정적 사본은 스크린 리더가 계속 읽을 수 있습니다.
| 카테고리 | 데이터 표시 |
|---|---|
| 유형 | Web Component (<mv-curtain>) |
| 상태 | 안정 |
| 키트 | 스스로 설명하는 숫자 |
| 함께 설치되는 항목 | button |
| Keywords | exclusive, culture, explainability, transparency, provenance, data-table, chart-accessibility, kpi, metrics, formula, sql, sources, freshness, csv-export, lazy-load, disclosure, dashboard, trust, audit |
When to use
- A dashboard chart or KPI must let finance, auditors or customers check the numbers, formula and source behind it
- A price, score, risk rating or AI answer needs a “show your work” view without leaving the page
- A chart needs an accessible text and table alternative that sighted users can also open and export
- The breakdown behind a headline figure is expensive and should be fetched only when someone asks for it
Avoid when
- You still need to draw the chart itself: this wraps an existing visual → use Chart instead
- The table is the main content, with search, selection, pagination or bulk actions → use Data table instead
- A one-line definition of a term is enough; a full backstage would be heavier than the question → use Tooltip instead
설치
node scripts/add.mjs curtain --out ./src/marvelousMarvelous UI MCP 서버를 사용하는 AI 에이전트: install_components({ slugs: ["curtain"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
복사되는 파일(의존성 포함): tokens/tokens.css, core/base.css, components/button/button.css, core/dom.js, core/element.js, core/focus.js, core/motion.js, core/observe.js, components/curtain/curtain.js, components/curtain/curtain.css.
사용법
빠른 시작, 동작하는 가장 작은 마크업:
<mv-curtain label="Net revenue retention, Q3 2026" handle-label="How is NRR calculated?" updated="2026-09-24T08:00:00Z">
<strong>112%</strong>
<pre slot="method">NRR = (Starting MRR + Expansion − Contraction − Churn) ÷ Starting MRR</pre>
</mv-curtain>기본 마크업입니다. 여기서 시작해 속성, data-*, CSS 변수로 커스터마이즈하세요:
<div id="cu-demo" style="width:min(100%,56rem);margin-inline:auto">
<style>
#cu-demo { display:grid; gap:1rem; align-content:start }
#cu-demo .cu-card { min-width:0; padding:1.125rem 1.25rem 0.75rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm) }
#cu-demo .cu-head { display:flex; align-items:flex-start; justify-content:space-between; gap:1rem; margin-bottom:.875rem }
#cu-demo .cu-title { margin:0; font-size:.9375rem; font-weight:600; letter-spacing:-.01em }
#cu-demo .cu-sub { margin:.125rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
#cu-demo .cu-figure { display:flex; align-items:baseline; gap:.625rem; flex-wrap:wrap; margin:0 0 .5rem }
#cu-demo .cu-figure strong { font-size:1.75rem; font-weight:650; letter-spacing:-.02em; font-variant-numeric:tabular-nums }
#cu-demo .cu-up { color:var(--mv-fg-muted); font-size:.8125rem; font-variant-numeric:tabular-nums }
#cu-demo .cu-up b { color:var(--mv-fg); font-weight:600 }
#cu-demo .cu-chart { width:100%; height:auto; overflow:visible }
#cu-demo .cu-chart .grid line { stroke:var(--mv-border); stroke-width:1 }
#cu-demo .cu-chart .grid line.base { stroke:var(--mv-border-strong) }
#cu-demo .cu-chart text { fill:var(--mv-fg-muted); font:11px var(--mv-font-sans); font-variant-numeric:tabular-nums }
#cu-demo .cu-chart .bars path { fill:color-mix(in oklab, var(--mv-accent) 58%, var(--mv-surface)); transition:fill var(--mv-duration-fast) }
#cu-demo .cu-chart .bars path[data-latest] { fill:var(--mv-accent) }
#cu-demo .cu-chart .bars path:hover { fill:var(--mv-accent-hover, var(--mv-accent)) }
#cu-demo .cu-chart text.cu-direct { fill:var(--mv-fg); font-weight:600 }
#cu-demo .cu-kpis { display:grid; grid-template-columns:repeat(2, minmax(0,1fr)); gap:1rem }
#cu-demo .cu-kpi { display:grid; gap:.25rem; padding:.125rem 0 .25rem }
#cu-demo .cu-kpi-label { color:var(--mv-fg-muted); font-size:.8125rem; font-weight:500 }
#cu-demo .cu-kpi-value { font-size:2.25rem; font-weight:650; letter-spacing:-.03em; line-height:1.1; font-variant-numeric:tabular-nums }
#cu-demo .cu-kpi-foot { display:flex; align-items:center; gap:.5rem; flex-wrap:wrap; color:var(--mv-fg-muted); font-size:.75rem }
#cu-demo .cu-controls { display:flex; align-items:center; justify-content:space-between; gap:.75rem 1.25rem; flex-wrap:wrap; padding:.75rem 1rem; border:1px dashed var(--mv-border); border-radius:var(--mv-radius-lg) }
#cu-demo .cu-log { margin:0; min-height:1.25rem; color:var(--mv-fg-subtle); font:.75rem/1.4 var(--mv-font-mono); flex:1 1 18rem }
#cu-demo .mv-choice { font-size:.8125rem }
@media (max-width:44rem) { #cu-demo .cu-kpis { grid-template-columns:minmax(0,1fr) } }
</style>
<!-- Main: a revenue chart with the query, sources and filters behind it -->
<section class="cu-card" aria-labelledby="cu-rev-title">
<div class="cu-head">
<div>
<h3 class="cu-title" id="cu-rev-title">Net revenue</h3>
<p class="cu-sub">Monthly, January to August 2026</p>
</div>
<span class="mv-badge" data-variant="secondary">USD</span>
</div>
<p class="cu-figure"><strong>$1,384,700</strong><span class="cu-up"><b>+4.0%</b> vs July</span></p>
<mv-curtain id="cu-revenue" label="Net revenue by month, Jan-Aug 2026" filename="net-revenue-2026">
<svg class="cu-chart" viewBox="0 0 640 236" role="img" aria-label="Bar chart of monthly net revenue, January to August 2026">
<g class="grid" aria-hidden="true">
<line x1="52" x2="632" y1="20" y2="20"/><line x1="52" x2="632" y1="84" y2="84"/><line x1="52" x2="632" y1="148" y2="148"/><line class="base" x1="52" x2="632" y1="212" y2="212"/>
<text x="44" y="24" text-anchor="end">$1.5M</text><text x="44" y="88" text-anchor="end">$1.0M</text><text x="44" y="152" text-anchor="end">$0.5M</text><text x="44" y="216" text-anchor="end">$0</text>
</g>
<g class="bars">
<path d="M69.3 212V72.1q0 -4 4 -4h30q4 0 4 4V212z"><title>Jan 2026: $1,124,300</title></path>
<path d="M141.8 212V68.6q0 -4 4 -4h30q4 0 4 4V212z"><title>Feb 2026: $1,151,800</title></path>
<path d="M214.3 212V63.2q0 -4 4 -4h30q4 0 4 4V212z"><title>Mar 2026: $1,193,400</title></path>
<path d="M286.8 212V65.4q0 -4 4 -4h30q4 0 4 4V212z"><title>Apr 2026: $1,176,900</title></path>
<path d="M359.3 212V57.5q0 -4 4 -4h30q4 0 4 4V212z"><title>May 2026: $1,238,600</title></path>
<path d="M431.8 212V51.2q0 -4 4 -4h30q4 0 4 4V212z"><title>Jun 2026: $1,287,200</title></path>
<path d="M504.3 212V45.6q0 -4 4 -4h30q4 0 4 4V212z"><title>Jul 2026: $1,331,500</title></path>
<path d="M576.8 212V38.8q0 -4 4 -4h30q4 0 4 4V212z" data-latest><title>Aug 2026: $1,384,700</title></path>
</g>
<text class="cu-direct" x="595.8" y="27" text-anchor="middle" aria-hidden="true">$1.38M</text>
<g aria-hidden="true">
<text x="88.3" y="230" text-anchor="middle">Jan</text><text x="160.8" y="230" text-anchor="middle">Feb</text><text x="233.3" y="230" text-anchor="middle">Mar</text><text x="305.8" y="230" text-anchor="middle">Apr</text>
<text x="378.3" y="230" text-anchor="middle">May</text><text x="450.8" y="230" text-anchor="middle">Jun</text><text x="523.3" y="230" text-anchor="middle">Jul</text><text x="595.8" y="230" text-anchor="middle">Aug</text>
</g>
</svg>
<script type="application/json" slot="data">
{
"columns": [
{ "key": "month", "label": "Month", "type": "date" },
{ "key": "net", "label": "Net revenue", "type": "currency", "format": { "maximumFractionDigits": 0 } },
{ "key": "newCustomers", "label": "New customers", "type": "number" },
{ "key": "churned", "label": "Churned MRR", "type": "currency", "format": { "maximumFractionDigits": 0 } },
{ "key": "refunds", "label": "Refunds", "type": "currency", "format": { "maximumFractionDigits": 0 } }
],
"rows": [
["2026-01", 1124300, 38, 18400, 6210],
["2026-02", 1151800, 41, 16900, 5480],
["2026-03", 1193400, 47, 21300, 7930],
["2026-04", 1176900, 35, 24800, 9120],
["2026-05", 1238600, 52, 15200, 4870],
["2026-06", 1287200, 49, 17600, 5560],
["2026-07", 1331500, 56, 14900, 6040],
["2026-08", 1384700, 61, 15900, 5310]
]
}
</script>
<div slot="method">
<p>Net revenue is recognized subscription and usage revenue for the month, minus refunds and credit notes, converted to USD at each month’s closing rate. Taxes are excluded.</p>
<pre data-language="sql">SELECT date_trunc('month', recognized_at) AS month,
SUM(amount_usd) - SUM(refund_usd) AS net_revenue
FROM finance.fct_revenue_lines
WHERE recognized_at >= DATE '2026-01-01'
AND recognized_at < DATE '2026-09-01'
AND NOT is_test_account
GROUP BY 1
ORDER BY 1;</pre>
</div>
</mv-curtain>
</section>
<!-- KPIs: the real formula behind a headline figure; data lazy-loaded on reveal -->
<div class="cu-kpis">
<section class="cu-card" aria-label="Net revenue retention">
<mv-curtain id="cu-nrr" label="Net revenue retention, Q3 2026" handle-label="How is NRR calculated?" tab="method">
<div class="cu-kpi">
<span class="cu-kpi-label">Net revenue retention</span>
<span class="cu-kpi-value">112%</span>
<span class="cu-kpi-foot"><span class="mv-badge" data-variant="success">+3.4 pts</span> vs Q2 2026 · trailing 12 months</span>
</div>
<div slot="method">
<p>Revenue kept and grown from customers who were already paying 12 months ago. New customers are excluded, so the figure isolates expansion versus losses.</p>
<pre data-language="formula">NRR = (Starting MRR + Expansion − Contraction − Churn) ÷ Starting MRR
= ($1,240,000 + $186,400 − $21,700 − $15,900) ÷ $1,240,000
= $1,388,800 ÷ $1,240,000
= 112.0%</pre>
</div>
</mv-curtain>
</section>
<section class="cu-card" aria-label="Average deal size">
<mv-curtain id="cu-deal" label="Average deal size, Q3 2026" handle="corner" stale-after="24h" tab="sources">
<div class="cu-kpi">
<span class="cu-kpi-label">Average deal size</span>
<span class="cu-kpi-value">$48,200</span>
<span class="cu-kpi-foot">Closed-won, Q3 2026 to date · 57 deals</span>
</div>
<div slot="method">
<p>Sum of first-year contract value of closed-won deals, divided by the number of those deals. Renewals and add-ons are excluded.</p>
<pre data-language="formula">Average deal size = $2,747,400 ÷ 57 deals = $48,200</pre>
</div>
</mv-curtain>
</section>
</div>
<div class="cu-controls">
<p class="cu-log" id="cu-log">Pull back a curtain to see how a figure is made.</p>
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="cu-motion"> Reduce motion</label>
</div>
<script type="module">
const log = document.getElementById("cu-log");
const say = (text) => { log.textContent = text; };
const ago = (h) => new Date(Date.now() - h * 36e5).toISOString();
// Revenue chart: sources and filters from app data.
const revenue = document.getElementById("cu-revenue");
revenue.updated = ago(2.2);
revenue.sources = [
{ label: "Billing ledger", href: "https://billing.example.com/", detail: "Invoices, refunds and credit notes", updated: ago(2.2) },
{ label: "Finance warehouse · fct_revenue_lines", detail: "dbt model, rebuilt hourly", updated: ago(2.6) },
{ label: "Month-end FX rates", detail: "Closing rates, 14 currencies", updated: ago(24 * 23) },
];
revenue.filters = [
{ label: "Period", value: "Jan 1 to Aug 31, 2026" },
{ label: "Currency", value: "USD, converted at month-end closing rates" },
{ label: "Region", value: "All regions" },
{ label: "Excluded", value: "Test accounts, internal workspaces, taxes" },
];
// NRR: the breakdown is fetched only when someone looks behind the figure.
const nrr = document.getElementById("cu-nrr");
nrr.updated = ago(0.4);
let nrrLoaded = false;
nrr.addEventListener("mv-reveal", (e) => {
if (nrrLoaded) return;
say("mv-reveal · loading the NRR breakdown…");
e.detail.waitUntil(new Promise((resolve) => setTimeout(() => {
nrrLoaded = true;
resolve({
data: {
columns: [
{ key: "segment", label: "Segment" },
{ key: "start", label: "Starting MRR", type: "currency", format: { maximumFractionDigits: 0 } },
{ key: "expansion", label: "Expansion", type: "currency", format: { maximumFractionDigits: 0 } },
{ key: "contraction", label: "Contraction", type: "currency", format: { maximumFractionDigits: 0 } },
{ key: "churn", label: "Churn", type: "currency", format: { maximumFractionDigits: 0 } },
{ key: "nrr", label: "NRR", type: "percent" },
],
rows: [
["Enterprise", 720000, 132000, 9800, 4200, 1.1639],
["Mid-market", 380000, 44600, 7900, 6300, 1.08],
["SMB", 140000, 9800, 4000, 5400, 1.0029],
],
},
sources: [
{ label: "Subscription ledger", detail: "MRR snapshots on Oct 1, 2025 and Sep 23, 2026", updated: ago(0.4) },
{ label: "CRM · Accounts", href: "https://crm.example.com/", detail: "Segment assignment", updated: ago(5) },
],
filters: [
{ label: "Cohort", value: "Customers paying on Oct 1, 2025 (1,284 accounts)" },
{ label: "Window", value: "Trailing 12 months, ending Sep 23, 2026" },
{ label: "Excluded", value: "New logos, one-off services" },
],
});
say("Breakdown loaded · 3 segments");
}, 700)));
});
// Average deal size: data older than the 24 h freshness budget is flagged.
const deal = document.getElementById("cu-deal");
deal.updated = ago(31);
deal.sources = [{ label: "CRM · Opportunities", href: "https://crm.example.com/", detail: "Stage = Closed Won, first-year ACV", updated: ago(31) }];
deal.data = {
columns: [
{ key: "segment", label: "Segment" },
{ key: "deals", label: "Deals", type: "number" },
{ key: "acv", label: "First-year ACV", type: "currency", format: { maximumFractionDigits: 0 } },
{ key: "avg", label: "Average", type: "currency", format: { maximumFractionDigits: 0 } },
],
rows: [
["Enterprise", 14, 1573600, 112400],
["Mid-market", 22, 858000, 39000],
["SMB", 21, 315800, 15038],
],
};
deal.filters = [{ label: "Stage", value: "Closed Won" }, { label: "Close date", value: "Jul 1 to Sep 23, 2026" }];
for (const el of [revenue, nrr, deal]) {
el.addEventListener("mv-reveal", (e) => { if (!log.textContent.startsWith("mv-reveal")) say(`mv-reveal · ${el.label}`); });
el.addEventListener("mv-export", (e) => say(`mv-export · ${e.detail.filename} (${e.detail.rows.length} rows)`));
el.addEventListener("mv-sort", (e) => say(`mv-sort · ${e.detail.key ?? "original order"} ${e.detail.direction ?? ""}`));
el.addEventListener("mv-copy", (e) => say(`mv-copy · ${e.detail.ok ? "copied" : "failed"}`));
}
const motion = document.getElementById("cu-motion");
motion.checked = document.documentElement.dataset.motion === "reduce";
motion.addEventListener("change", () => {
if (motion.checked) document.documentElement.dataset.motion = "reduce";
else delete document.documentElement.dataset.motion;
});
</script>
</div>API
Attributes
| Name | 유형 | Default | Description |
|---|---|---|---|
open | boolean | false | Backstage shown. Setting it runs the same path as the handle (mv-reveal, which may veto it and removes the attribute again). Reflected by the component. |
label | string | Visualization | Name of the figure (“Net revenue by month, Jan-Aug 2026”): accessible name of the visual group, backstage label (“How … is calculated”), table caption, auto-summary and default CSV filename. |
tab | data | method | sources | filters | data | Tab shown when the backstage opens. Falls back to the first tab that has content; reflected when the user picks another tab. |
side | start | end | start | Direction the visual is pulled: toward the inline start (default) or end. Mirrors naturally in RTL layouts. |
handle | bar | corner | bar | bar: a rail under the visual with freshness and a text handle. corner: an icon-only round handle floating over the top-end corner, for compact tiles (freshness moves to the Sources tab; a dot flags stale data). |
handle-label | string | How is this calculated? | Text of the handle while closed (e.g. “How is NRR calculated?”). While open it reads “Hide details”. |
updated | ISO date | epoch ms | When the data was last refreshed. Shown relative in the rail (“Updated 2 hours ago”, refreshed every 30 s to 5 min) with the absolute date in a <time> title, and as “Data as of …” in the Sources tab. | |
stale-after | duration ("24h", "90m", "7d", ms) | Freshness budget. Past it the host gets data-stale, the rail shows a “Stale” pill (a dot on the corner handle) and the Sources tab explains it. | |
summary | string | Screen-reader summary of the visual. Without it (and without slot="summary") one is generated from the data: row count, range of the first numeric column with where its extremes are, and the latest value. | |
locale | BCP 47 tag | en-US | Locale for numbers, currencies, dates, relative times and sorting. |
currency | ISO 4217 code | USD | Default currency of currency columns (a column may set its own). |
filename | string | CSV filename (.csv added). Default: the slugified label. | |
data-state | closed | opening | open | closing | Set by the component (styleable). data-side, data-handle and data-stale are set too. |
Properties
| Name | 유형 | Description |
|---|---|---|
data | { columns?, rows } | object[] | The data behind the visual. columns: string keys or { key, label?, type?: "text" | "number" | "currency" | "percent" (fractions) | "date", format?: Intl options, currency?, unit?, align? }; rows: objects or arrays in column order. Types are inferred when omitted (numbers, ISO dates such as "2026-08"). Reading returns plain copies. |
method | string | { title?, note?, code?, language? } | Array | Formula or query blocks rendered in the Method tab (code blocks get a language tag and a copy button). Adds to any slot="method" content. |
sources | Array<string | { label, href?, detail?, updated? }> | Where the figure comes from. Links open in a new tab (rel="noopener noreferrer"), updated shows relative + absolute time. The first one is named in the rail. |
filters | Array<{ label, value }> | Record<string, string> | Filters and exclusions applied to the figure (period, region, excluded accounts…), shown as a definition list; the tab shows their count. |
strings | Partial<Record<string, string>> | Overrides for every visible text and announcement (handle, handleClose, panel ({label}), tabs, data, method, sources, filters, rows ({count}), exportCsv, copy, copied, copyFailed, loading, error, retry, updated ({time}), asOf ({date}), stale, staleText, caption, sortedAsc/sortedDesc ({column}), exported, opened/closed ({label}), summaryAuto, summaryRows…). English defaults. |
state | "closed" | "opening" | "open" | "closing" | Current phase (read-only). |
Methods
| Name | Description |
|---|---|
show({ tab?, focus? }) | Pulls the curtain back (emits the cancelable mv-reveal). Returns Promise<boolean>: false if vetoed; resolves once lazy content has settled. |
hide({ focus? }) / toggle(force?) | Lets the curtain fall back over the visual (focus returns to the handle when it was inside). |
sortBy(key, direction) | Sorts the table ("ascending" | "descending"; null key or direction restores the original order). Stable, blanks last, locale-aware for text. |
toCSV() | Returns the data as CSV: raw values, current sort order, CRLF line endings, text cells starting with = + - @ prefixed with an apostrophe. |
exportCSV() | Emits the cancelable mv-export, then downloads the CSV (UTF-8 with BOM, opens cleanly in spreadsheet apps). Returns false if cancelled or there is no data. |
Events
| Name | Description |
|---|---|
mv-reveal | Cancelable, before the backstage opens (and on Retry, with retry: true). detail: { tab, retry, waitUntil(promise) }. Call waitUntil() synchronously to lazy-load: the backstage opens at once with a skeleton (aria-busy), then the resolved object ({ data, method, sources, filters, summary, updated }, any subset) is merged in; a rejection shows an error with Try again. preventDefault() keeps the curtain closed. |
mv-reveal-error | A waitUntil promise rejected. detail: { error }. |
mv-conceal | The backstage started closing. |
mv-tab | The user picked another tab. detail: { tab }. |
mv-sort | The table sort changed. detail: { key, direction } (both null for the original order). |
mv-export | Cancelable, before the CSV download. detail: { csv, filename, columns, rows }. preventDefault() to export yourself (server-side XLSX, a signed URL…). |
mv-copy | A code block was copied. detail: { text, ok }. |
Content structure
| Name | Description |
|---|---|
(default) | The visual: an SVG chart, an <mv-chart>, a KPI tile, an image, an AI answer… It stays exactly as authored; it is only wrapped and hidden (inert) while the backstage shows. |
data | <script type="application/json" slot="data"> with the data object, or a <table slot="data"> (header cells become columns; data-value on a cell gives the raw sortable value, data-type on a header cell sets the type). A slotted table is parsed and hidden; the component renders its own. |
method / sources / filters | Any rich content moved into that tab after the generated part. Every <pre> inside slot="method" becomes a code block with a copy button (language from data-language or a language-* class on its <code>). |
summary | Your own screen-reader summary of the visual (replaces the generated one). |
CSS classes
| Name | Description |
|---|---|
mv-curtain-frame / -stage / -edge / -backstage | The shared cell, the wrapped visual, the fold shadow that rides the moving edge, and the backstage panel (-head, -tabs, -tab, -count, -panel, -status). |
mv-curtain-table / -toolbar / -scroll | Data tab: sortable table (sticky header, first column as row headers, numbers end-aligned, aria-sort), row count and sort note, Export CSV button, focusable scroll region. |
mv-curtain-code / -code-bar / -lang / -copy / -pre | Method code blocks. |
mv-curtain-sources / -src / -asof / mv-curtain-filters / -filter | Sources list (icon, name, meta), the “Data as of” line with stale callout, and the filters definition list. |
mv-curtain-rail / -fresh / -dot / -stale / -handle | The rail under the visual (or the corner handle). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-curtain-radius | var(--mv-radius-lg) | Corner radius of the frame and backstage. |
--mv-curtain-max-height | 24rem | Tallest the backstage may grow beyond the visual’s own height; longer content scrolls inside it. |
--mv-curtain-stage-bg | var(--mv-surface) | Background given to the visual while it slides (so the drape is opaque). Match it to the surface the visual sits on. |
--mv-curtain-backstage | var(--mv-bg-subtle) | Backstage background. |
--mv-curtain-fold | color-mix(in oklab, var(--mv-fg) 18%, transparent) | Color of the fold shadow on the moving edge. |
--mv-curtain-fresh / --mv-curtain-stale | var(--mv-success) / var(--mv-warning) | Freshness dot, and the stale pill, dot and callout. |
Accessibility
The handle is a real disclosure <button> with aria-expanded and aria-controls pointing to the backstage, a labelled region (“How Net revenue retention is calculated”); the corner variant keeps the text as its aria-label (with “(Stale)” when the data is past its budget). Opening from the handle moves focus to the active tab, or to the busy backstage while lazy data loads and then to the tab; Escape anywhere inside, or the handle again, closes it and returns focus to the handle. Opening, closing, sorting, exporting and copying are announced in a polite live region. Tabs follow the ARIA tabs pattern (role tablist/tab/tabpanel, roving tabindex, Arrow keys, Home and End, automatic activation); tabs without content are not rendered. The data table has a caption, column headers with sort buttons and aria-sort on the sorted column only, and the first column as row headers; its scroll area is a focusable, labelled region. While the backstage shows, the visual is inert and aria-hidden. While closed, the visual is wrapped in a named group described (aria-describedby) by a summary, generated from the data when you don't provide one, and a static, control-free copy of the table follows it, visually hidden, so screen-reader users always have the numbers: the component doubles as the accessible alternative for the chart. Freshness uses <time datetime> with the absolute date as title; stale data is flagged with words, not only color. External source links announce that they open in a new tab. Reduced motion (OS or data-motion="reduce"): a 180 ms crossfade replaces the drape, with no sliding or height motion, and the loading shimmer stops. Forced colors: borders, the selected tab and the freshness dot use system colors; the fold shadow is removed.