Exclusivebeta
Curtain — <mv-curtain>
“How is this calculated?” for any polished figure: wrap a chart, KPI, score, price or AI answer and a handle pulls the visual aside like a drape (a clip wipe with a soft fold shadow; a plain crossfade under reduced motion) to reveal its backstage in the same footprint, with no layout jump. Tabs show only what you provide: Data (an accessible, sortable table built from a data property, a JSON script or a slotted <table>, formatted with Intl for currency, percent, number and dates, plus RFC 4180 CSV export that is safe against spreadsheet formula injection), Method (formula or query in code blocks with a copy button, plus any rich explanation), Sources (links, detail, relative and absolute update times, a “data as of” line and a stale warning past stale-after) and Filters. A rail under the visual keeps freshness in sight (“Updated 2 hours ago · Stripe Billing +2”); a corner variant fits compact KPI tiles and flags stale data with a dot. mv-reveal is cancelable and its waitUntil(promise) lazy-loads the backstage (skeleton, error and retry included). While closed it doubles as the chart’s accessible alternative: an auto-generated summary (range, extremes, latest value) is linked with aria-describedby and a static copy of the table stays readable by screen readers.
| Category | Data display |
|---|---|
| Type | Web Component (<mv-curtain>) |
| Status | beta |
| Also installs | 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
Install
node scripts/add.mjs curtain --out ./src/marvelousAI agent with the Marvelous UI MCP server: install_components({ slugs: ["curtain"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Files copied (dependencies included): tokens/tokens.css, core/base.css, core/dom.js, core/element.js, core/focus.js, core/motion.js, core/observe.js, components/curtain/curtain.js, components/curtain/curtain.css, components/button/button.css.
Usage
Canonical markup — start from it and customize with attributes, data-* and CSS variables:
<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: "Stripe Billing", href: "https://stripe.com/billing", 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 – 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: "Salesforce · Accounts", href: "https://www.salesforce.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: "Salesforce · Opportunities", href: "https://www.salesforce.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 – 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>Cultural reference
The Wizard of Oz — Victor Fleming (MGM, after L. Frank Baum's 1900 novel) (1939, film). A little dog pulls back a curtain and reveals that the great and powerful wizard is an ordinary man working levers: the impressive show had a simple, inspectable mechanism behind it. In the UI, any polished figure can be pulled aside to reveal how it is made, its data, formula, sources, freshness and filters, right where it stands.
API
Attributes
| Name | Type | 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 | Type | 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.