Exclusivobeta
Curtain <mv-curtain>
“¿Cómo se calcula esto?” para cualquier cifra pulida: envuelve un gráfico, KPI, puntuación, precio o respuesta de IA y un tirador aparta lo visual como un telón (un barrido por recorte con una suave sombra de pliegue; un simple fundido con movimiento reducido) para revelar su trastienda en el mismo espacio, sin saltos de layout. Las pestañas muestran solo lo que aportas: Data (una tabla accesible y ordenable construida a partir de una propiedad data, un script JSON o una <table> en slot, formateada con Intl para moneda, porcentaje, número y fechas, más exportación CSV RFC 4180 protegida contra la inyección de fórmulas en hojas de cálculo), Method (fórmula o consulta en bloques de código con botón de copiar, más cualquier explicación enriquecida), Sources (enlaces, detalle, horas de actualización relativas y absolutas, una línea “datos a fecha de” y un aviso de obsolescencia pasado stale-after) y Filters. Un riel bajo lo visual mantiene a la vista la frescura de los datos (“Actualizado hace 2 horas · Stripe Billing +2”); una variante de esquina encaja en tarjetas KPI compactas y marca los datos obsoletos con un punto. mv-reveal es cancelable y su waitUntil(promise) carga la trastienda de forma diferida (esqueleto, error y reintento incluidos). Mientras está cerrado, sirve además como alternativa accesible del gráfico: un resumen generado automáticamente (rango, extremos, último valor) se vincula con aria-describedby y una copia estática de la tabla sigue siendo legible por los lectores de pantalla.
| Categoría | Visualización de datos |
|---|---|
| Tipo | Web Component (<mv-curtain>) |
| Estado | beta |
| Kit | Cifras que se explican solas |
| También instala | 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
Instalación
node scripts/add.mjs curtain --out ./src/marvelousAgente de IA con el servidor MCP de Marvelous UI: install_components({ slugs: ["curtain"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Archivos copiados (dependencias incluidas): 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.
Uso
Inicio rápido, el marcado mínimo que funciona:
<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>Marcado de referencia: parte de él y personalízalo con atributos, data-* y variables 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: "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 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: "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 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>Referencia cultural
El mago de Oz, Victor Fleming (MGM, basada en la novela de 1900 de L. Frank Baum) (1939, película). Un perrito descorre una cortina y revela que el grande y poderoso mago es un hombre corriente que maneja palancas: el impresionante espectáculo tenía detrás un mecanismo sencillo y examinable. En la interfaz, cualquier cifra pulida puede apartarse para revelar cómo está hecha, sus datos, fórmula, fuentes, frescura y filtros, justo donde está.
API
Attributes
| Name | Tipo | 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 | Tipo | 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.