Exclusivobeta
Why Not <mv-why-not>
Recusa explicável: nenhum “não” sem um motivo e um caminho a seguir. Envolva um controle recusado (um botão Publish, um item de menu, um link, um select) e ele continua focável com aria-disabled em vez de disabled, mostra um pequeno cadeado, relógio ou ampulheta no canto, antecipa o motivo ao passar o mouse ou no foco pelo teclado e, no clique, Enter ou Espaço, abre um painel não modal em vez de não fazer nada em silêncio; os próprios handlers de clique, a navegação e os envios de formulário do app nunca disparam enquanto ele está recusado. Sem um controle, ele renderiza o mesmo conteúdo como um card independente para páginas 403, pedidos rejeitados, pagamentos recusados e remoções por moderação. Ele sempre responde a quatro perguntas, com as palavras do app e alternativas sensatas: o que foi recusado, por quê (a regra, para este caso), quem decide (uma pessoa com iniciais e contato, uma equipe, uma política ou um sistema automatizado) e o que você pode fazer (caminhos ordenados, o primeiro recomendado). Um caminho de pedido ou de recurso passa por um evento mv-request / mv-appeal cancelável com waitUntil(promise) e depois mostra o status no lugar (“Requested 2h ago from Maya Chen · Usually answered within a day”) com um botão Cancel request; grant() suspende a recusa com um breve anel de sucesso, decline() mostra a resposta. “Ainda não” (uma cota, uma verificação, uma data until que faz contagem regressiva e suspende a recusa no horário) se distingue de “não permitido” por palavras e ícone, nunca só pela cor; o tom continua tranquilo, um código de referência pode ser exibido, e “Copy details” coloca na área de transferência um resumo pronto para o suporte.
| Categoria | Feedback |
|---|---|
| Tipo | Web Component (<mv-why-not>) |
| Status | beta |
| Kit | Estados do sistema honestos |
| Keywords | exclusive, culture, permission, access-denied, forbidden, 403, disabled-button, aria-disabled, request-access, appeal, paywall, quota, moderation, payment-failed, explanation, rbac, empty-state, trust |
When to use
- A button or menu item is unavailable for this user and must say why and how to get access instead of greying out silently
- A 403 page, rejected application, failed payment or removed post needs a clear reason, who decided and a way to appeal
- People can request access or an approval in one click and should see that the request is pending and can be cancelled
- A limit is temporary (quota, trial, verification) and users must know it lifts on its own and when
Avoid when
- The control is available and only needs a short label on hover or focus → use Tooltip instead
- Something cannot be enabled because its requirements loop back on each other or conflict → use Catch Loop instead
- The user can act but is missing fields or struggling with a form, which calls for guidance rather than a refusal → use One More Thing instead
Instalação
node scripts/add.mjs why-not --out ./src/marvelousAgente de IA com o servidor MCP do Marvelous UI: install_components({ slugs: ["why-not"], target_dir: "<absolute path>/src/marvelous", framework: "react" }).
Arquivos copiados (dependências incluídas): tokens/tokens.css, core/base.css, core/dismiss.js, core/dom.js, core/element.js, core/motion.js, core/observe.js, core/pointer.js, core/position.js, components/why-not/why-not.js, components/why-not/why-not.css.
Uso
Início rápido, a menor marcação que funciona:
<mv-why-not why="Publishing needs the Editor role. You are a Viewer." decider="Maya Chen">
<button>Publish</button>
<button data-why-not-path="request">Request Editor access</button>
</mv-why-not>Marcação de referência, para usar como ponto de partida e personalizar com atributos, data-* e variáveis CSS:
<div id="wn-demo" style="width:min(100%,60rem);margin-inline:auto">
<style>
#wn-demo { display:grid; gap:1rem; align-content:start }
#wn-demo .wn-app { border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-sm); overflow:hidden }
#wn-demo .wn-bar { display:flex; align-items:center; gap:.75rem 1rem; flex-wrap:wrap; min-height:3.5rem; padding:.625rem 1rem .625rem 1.125rem; border-bottom:1px solid var(--mv-border) }
#wn-demo .wn-crumbs { display:flex; align-items:center; gap:.375rem; min-width:0; font-size:.8125rem; color:var(--mv-fg-muted) }
#wn-demo .wn-crumbs b { color:var(--mv-fg); font-weight:600 }
#wn-demo .wn-crumbs svg { width:.875rem; height:.875rem; color:var(--mv-fg-subtle) }
#wn-demo .wn-tools { display:flex; align-items:center; gap:.625rem; flex-wrap:wrap; margin-inline-start:auto }
#wn-demo .wn-body { padding:1.25rem 1.125rem 1.25rem }
#wn-demo .wn-doc h3 { margin:0 0 .25rem; font-size:1.25rem; letter-spacing:-.015em }
#wn-demo .wn-meta { margin:0 0 1rem; color:var(--mv-fg-muted); font-size:.75rem }
#wn-demo .wn-doc p { margin:0 0 .75rem; color:var(--mv-fg-muted); font-size:.875rem; line-height:1.6; max-width:34rem }
#wn-demo .wn-doc ul { margin:0; padding-inline-start:1.125rem; color:var(--mv-fg-muted); font-size:.875rem; line-height:1.7 }
#wn-demo .wn-foot { display:grid; gap:.5rem; padding:.875rem 1.125rem; border-top:1px solid var(--mv-border); background:var(--mv-bg-subtle) }
#wn-demo .wn-controls { display:flex; align-items:center; gap:.625rem 1.125rem; flex-wrap:wrap }
#wn-demo .mv-choice { font-size:.8125rem }
#wn-demo .wn-log { margin:0; min-height:1.125rem; color:var(--mv-fg-subtle); font:.6875rem/1.5 var(--mv-font-mono); overflow-wrap:anywhere }
#wn-demo .wn-standalone { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:1rem; align-items:stretch }
#wn-demo .wn-caption { margin:.25rem 0 -.25rem; color:var(--mv-fg-muted); font-size:.75rem; font-weight:500 }
@media (max-width:46rem) {
#wn-demo .wn-standalone { grid-template-columns:minmax(0,1fr) }
}
</style>
<!-- A docs editor seen by a Viewer: three refused actions, each explaining itself -->
<section class="wn-app" aria-label="Page editor">
<header class="wn-bar">
<span class="wn-crumbs">
Launch
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 6 6 6-6 6"/></svg>
<b>Q4 launch plan</b>
<span class="mv-badge" data-variant="secondary">Viewer</span>
</span>
<span class="wn-tools">
<!-- Pending: a request is already waiting for an answer -->
<mv-why-not id="wn-invite" placement="bottom-end"
what="You can’t invite guests to this page"
why="People outside Northwind need an admin’s approval before they can see internal pages."
decider="Workspace admins" decider-type="team" decider-role="IT · Northwind"
response-time="within 4 hours" pending="request" pending-from="Workspace admins">
<button type="button" class="mv-button" data-variant="ghost" data-size="sm">Invite guest</button>
<button data-why-not-path="request">Ask admins to approve a guest</button>
<button data-why-not-path data-hint="Anyone at Northwind can be added right away">Invite a teammate instead</button>
</mv-why-not>
<!-- Not yet: a quota that resets on its own -->
<mv-why-not id="wn-export" kind="not-yet" placement="bottom-end"
what="You’ve used this month’s PDF exports"
why="The Starter plan includes 20 PDF exports per month, and all 20 have been used."
decider="Starter plan" decider-type="policy" decider-role="20 exports per month">
<button type="button" class="mv-button" data-variant="outline" data-size="sm">Export PDF</button>
<button data-why-not-path data-hint="Unlimited exports, $12 per member per month">Upgrade to Team</button>
<button data-why-not-path data-hint="Paste it into any doc or email">Copy the page as Markdown</button>
</mv-why-not>
<!-- Not allowed: a role the person doesn't have (opened on load) -->
<mv-why-not id="wn-publish" open placement="bottom-end"
what="You can’t publish this page"
why="Publishing needs the Editor role in Launch. You are a Viewer."
decider="Maya Chen" decider-role="Space owner · Launch" contact="[email protected]"
response-time="within a day" reference="PERM-EDITOR-LAUNCH">
<button type="button" class="mv-button" data-size="sm">Publish</button>
<button data-why-not-path="request">Request Editor access</button>
<button data-why-not-path data-hint="She gets a link to this draft">Ask Maya to publish it</button>
<button data-why-not-path data-hint="Your changes stay private for now">Save as draft instead</button>
</mv-why-not>
</span>
</header>
<div class="wn-body">
<article class="wn-doc">
<h3>Q4 launch plan</h3>
<p class="wn-meta">Draft · edited 12 minutes ago by Kenji Watanabe</p>
<p>We ship the new billing dashboard to all Team and Business workspaces on October 20. The rollout runs in three waves over two weeks, starting with the 40 design partners who joined the beta in July.</p>
<ul>
<li>Oct 6 · docs freeze and support training</li>
<li>Oct 20 · wave 1, design partners</li>
<li>Oct 27 · wave 2, Team plans in North America and Europe</li>
<li>Nov 3 · wave 3, everyone else</li>
</ul>
</article>
</div>
<footer class="wn-foot">
<span class="wn-controls">
<label class="mv-choice"><input type="checkbox" role="switch" class="mv-switch" id="wn-grant"> Maya grants Editor access</label>
<label class="mv-choice"><input type="checkbox" class="mv-checkbox" id="wn-fail"> Make requests fail</label>
<button type="button" class="mv-button" data-variant="outline" data-size="sm" id="wn-decline">Maya declines</button>
<button type="button" class="mv-button" data-variant="ghost" data-size="sm" id="wn-reset">Reset</button>
</span>
<p class="wn-log" id="wn-log" aria-live="polite">Hover, focus or click a refused action in the toolbar.</p>
</footer>
</section>
<p class="wn-caption">Standalone, for pages and messages that are themselves the refusal</p>
<div class="wn-standalone">
<mv-why-not id="wn-payment" kind-label="Payment declined"
what="Your payment didn’t go through"
why="Your bank declined the $240.00 charge for the Team plan on the Visa ending in 4242. No money was taken."
decider="Your card issuer" decider-type="system" decider-role="Declined with code 51, insufficient funds"
reference="PAY-7Q2X-91">
<button data-why-not-path data-hint="Your workspace stays active while you fix this">Use a different card</button>
<button data-why-not-path data-hint="Worth a try once your bank confirms">Retry the payment</button>
<a data-why-not-path href="#billing-help">Why banks decline payments</a>
</mv-why-not>
<mv-why-not id="wn-post" kind-label="Post removed"
what="Your post was removed from Plant Swap"
why="It links to a paid seed shop, and rule 3 of the group doesn’t allow selling or affiliate links."
decider="Community moderators" decider-type="team" decider-role="Plant Swap · 4 volunteers"
response-time="within 3 days" reference="MOD-20931">
<button data-why-not-path data-hint="Your text and photos are kept">Edit and repost without the link</button>
<a data-why-not-path href="#group-rules">Read the group rules</a>
<button data-why-not-path="appeal">Appeal this decision</button>
</mv-why-not>
</div>
<script type="module">
const $ = (id) => document.getElementById(id);
const publish = $("wn-publish");
const invite = $("wn-invite");
const exp = $("wn-export");
const log = $("wn-log");
const say = (text) => { log.textContent = text; };
// The quota resets on the 1st of next month, at midnight.
const now = new Date();
exp.until = new Date(now.getFullYear(), now.getMonth() + 1, 1).toISOString();
// The guest request was sent two hours ago.
invite.pendingSince = new Date(Date.now() - 2 * 3600e3 - 4 * 60e3).toISOString();
// A fake API: 700 ms round trip, optionally failing.
const api = () => new Promise((resolve, reject) => {
setTimeout(() => ($("wn-fail").checked ? reject(new Error("503")) : resolve()), 700);
});
const root = $("wn-demo");
root.addEventListener("mv-why", (e) => say(`mv-why · ${e.detail.via} · “${e.detail.what}”${e.detail.attempts ? ` · attempt ${e.detail.attempts}` : ""}`));
root.addEventListener("mv-request", (e) => { say(`mv-request · ${e.detail.label}…`); e.detail.waitUntil(api().then(() => say("Request delivered."))); });
root.addEventListener("mv-appeal", (e) => { say(`mv-appeal · ${e.detail.reference}…`); e.detail.waitUntil(api()); });
root.addEventListener("mv-withdraw", (e) => { say(`mv-withdraw · ${e.detail.type}…`); e.detail.waitUntil(api()); });
root.addEventListener("mv-request-error", (e) => say(`mv-request-error · ${e.detail.type} · ${e.detail.error.message}`));
root.addEventListener("mv-path", (e) => say(`mv-path · ${e.detail.label}`));
root.addEventListener("mv-lift", () => say("mv-lift · the quota reset"));
root.addEventListener("click", (e) => { if (e.target.closest("a[href^='#']")) e.preventDefault(); });
$("wn-grant").addEventListener("change", (e) => {
if (e.target.checked) { publish.grant(); say("Maya granted Editor access: Publish works now."); }
else { publish.allowed = false; say("Editor access removed."); }
});
$("wn-decline").addEventListener("click", () => {
$("wn-grant").checked = false;
publish.allowed = false;
publish.decline("Maya declined on " + new Date().toLocaleDateString("en-US", { month: "short", day: "numeric" }) + ": “Only the launch team publishes until the Oct 6 docs freeze.”");
say("Maya declined the request.");
});
$("wn-reset").addEventListener("click", () => {
$("wn-grant").checked = false;
$("wn-fail").checked = false;
publish.allowed = false;
publish.pending = null;
publish.note = null;
invite.pending = "request";
invite.pendingSince = new Date(Date.now() - 2 * 3600e3 - 4 * 60e3).toISOString();
say("Reset.");
});
// The Publish button only does its job once allowed.
publish.querySelector(".mv-button").addEventListener("click", () => say("Published “Q4 launch plan”."));
</script>
</div>Referência cultural
O Processo, Franz Kafka (1925, livro). Um homem é preso e processado sem nunca saber a acusação, quem o julga nem como recorrer, e cada porta em que ele bate o recusa sem explicação. A interface é o inverso exato: uma recusa nunca vem sem a regra por trás dela, a pessoa ou o sistema que decide e caminhos concretos a seguir, incluindo pedidos e recursos em um clique cujo andamento continua visível.
API
Attributes
| Name | Tipo | Default | Description |
|---|---|---|---|
what | string | What was refused, as a title (“You can’t publish this page”). Fallback: “‘Publish’ isn’t available”, from the control’s label. | |
why | string | The rule, specific to this case (“Publishing needs the Editor role in Launch. You are a Viewer.”). Also read by screen readers on the control and shown in the peek. Fallback: a neutral sentence. | |
kind | not-allowed | not-yet | not-allowed (not-yet when until is set) | Policy refusal or temporary one. Changes the eyebrow (“Not allowed” / “Not yet”), the icon (lock / clock) and the tone. |
kind-label | string | Replaces the eyebrow text (“Payment declined”, “Post removed”). | |
until | ISO date-time | ms epoch | When a temporary refusal lifts (a quota reset, a trial start). Shown as “in 6 days · Thu, Oct 1, 12:00 AM” and kept current; when it passes, the cancelable mv-lift fires and the control is allowed. | |
decider | string | Who decides: a person (“Maya Chen”), a team, a policy or a system. Fallback: “Your administrator”, or “Automatic” for a not-yet refusal with until. | |
decider-type | person | team | policy | system | person | Initials avatar for a person, an icon otherwise, and a default role line (Team, Policy, Automated check). |
decider-role | string | Second line under the decider (“Space owner · Launch”, “Declined with code 51”). | |
contact | email | URL | text | How to reach the decider. An email becomes a mailto link, a URL a link. With no path declared, a “Contact {decider}” path is generated from it. | |
response-time | string | How long an answer usually takes (“within a day”): the hint of request and appeal paths and the second line of the pending status. | |
reference | string | Case or error reference shown in the footer and included in Copy details (“MOD-20931”, “Error 403”). | |
note | string | An extra line under the facts, typically the decider’s answer after decline(). | |
pending | request | appeal | A request or appeal is waiting for an answer: its path is replaced by the status and a withdraw button. Set by the component after a successful request, or server-rendered. | |
pending-since | ISO date-time | ms epoch | When the request was sent (“Requested 2h ago”). Set by the component; updated every minute. | |
pending-from | string | decider | Who the request went to, when it differs from the decider. |
allowed | boolean | Lifts the refusal: the control works normally, the badge, peek and panel are gone, ARIA is restored (a standalone card hides). Bind it to your permission check. Becoming allowed after an attempt announces it and rings the control once. | |
mode | popover | inline | popover (inline without a control) | popover: a non-modal dialog anchored to the control in the top layer. inline: a disclosure right under the control, in the flow. Without a control the component is always a standalone card. |
open | boolean | Reflects whether the explanation is open; set it to open on load (it never steals focus then). | |
placement | top | bottom | left | right, with -start | -end | bottom-start | Preferred side of the popover (flips and shifts to stay in view). The peek goes to the other vertical side. |
peek | hover | none | hover | Small reason bubble on pointer hover (after 380 ms) and keyboard focus of the control. Purely visual: the same text is the control’s accessible description. |
heading-level | 1-6 | Exposes the title as a heading of that level (use 1 on a full 403 page). | |
locale | BCP 47 | en-US | Locale for relative times and dates. |
data-why-not-target | marker attribute | Marks the refused control when it is not the first control child. | |
data-why-not-path | "" | request | appeal | On a child <button> or <a href>: a way forward, moved into the panel in document order (don’t style it: rows are drawn by the component). request / appeal go through mv-request / mv-appeal and the pending state; a link request/appeal navigates unless waitUntil or preventDefault is used. data-hint adds a second line, data-keep-open keeps the popover open, data-id is passed in mv-path. | |
data-mode / data-kind / data-blocked / data-pending / data-busy | set by the component | Styling hooks on the host. The control gets data-why-not-control while refused and data-why-not-granted during the success ring. |
Properties
| Name | Tipo | Description |
|---|---|---|
control | Element | null | The refused control (read-only). |
paths | Array<{ label, hint?, type?: "request" | "appeal", href?, id?, keepOpen? }> | Ways forward from data, appended after the declared ones (framework-friendly). |
details | string | Plain-text summary (what, why, availability, decider, contact, status, reference, date, page URL), ready for a support message (read-only). |
attempts | number | How many times the refused control was activated (read-only), also in mv-why. |
strings | Partial<Record<string, string>> | Overrides for every default text and announcement (notAllowed, notYet, titleFor, whyFallback, whoLabel, optionsLabel, eta, pendingRequest, pendingAppeal, withdrawRequest, requestError, declined, contactPath, fallbackPath, copy, peekPointer, peekKeyboard, announce*…). English defaults. |
Methods
| Name | Description |
|---|---|
show() / hide() / toggle() | Open or close the explanation (popover and inline modes). |
requestAccess() / appeal() | Run the request or appeal lifecycle from code. Resolve to true once pending, false if cancelled or failed. |
withdraw() | Cancel the pending request or appeal (cancelable mv-withdraw with waitUntil). |
grant() | The decider said yes: clears pending and note and sets allowed. |
decline(message?) | The decider said no: clears pending and shows message (default “{decider} declined your request.”) as the note, announced politely. |
copyDetails() | Copies details to the clipboard; resolves true on success. |
refresh() | Re-scan the control and new path children after a framework replaced them. |
Events
| Name | Description |
|---|---|
mv-why | The explanation was opened (or a standalone card was seen). detail: { via: "pointer" | "keyboard" | "api" | "view", attempts, kind, what, why, reference, pending }. For analytics: which refusals people hit. |
mv-request | Cancelable. A request path was chosen. detail: { type, label, what, why, reference, kind, path, waitUntil(promise) }. Call waitUntil() synchronously with your API call: the row shows a spinner, a rejection shows an error and keeps the path. preventDefault() stops it. |
mv-appeal | Cancelable. Same detail and lifecycle as mv-request, for the appeal path. |
mv-withdraw | Cancelable. The user cancels a pending request or appeal. detail: { type, waitUntil(promise) }. |
mv-request-error | A waitUntil promise rejected. detail: { type: "request" | "appeal" | "withdraw", error }. |
mv-path | Another way forward was chosen. detail: { type: "action" | "link" | "contact", label, id, path }. The popover then closes (unless data-keep-open). |
mv-lift | Cancelable. The until time passed; unless prevented, allowed is set. detail: { until }. |
CSS classes
| Name | Description |
|---|---|
mv-why-not-panel | The explanation (role=dialog in popover mode, role=group otherwise): -head, -icon, -kind, -title, -facts (dl of .mv-why-not-row[data-row=why|until|who]), -note, -options, -label, -paths, -error, -foot, -ref, -copy, -close. |
mv-why-not-path / mv-why-not-path-action | A way forward (<li>, data-primary on the recommended one, data-pending while its request waits) and its button or link: -path-icon, -path-label, -path-hint, -path-end. |
mv-why-not-status | Pending status (role=status): -status-icon, -status-title, -status-eta, .mv-why-not-withdraw. |
mv-why-not-badge | Icon on the refused control’s corner (lock, clock or hourglass). |
mv-why-not-peek | Hover / focus reason bubble (top layer, aria-hidden). |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-why-not-tone | var(--mv-fg-muted) | Tone of a “not allowed” refusal (icon tile, eyebrow). Neutral on purpose: a refusal is not an error. |
--mv-why-not-soon | var(--mv-info) | Tone of a “not yet” refusal. |
--mv-why-not-pending | var(--mv-accent) | Tone of a pending request or appeal. |
Accessibility
The refused control is never disabled: disabled is removed (and removed again if a framework puts it back) and aria-disabled="true" is set, so it stays in the tab order and is announced as dimmed or unavailable. Its aria-describedby gains a visually hidden sentence (“Unavailable. Publishing needs the Editor role in Launch. You are a Viewer. Activate to see why and what you can do.”, with the availability date or the pending status when there is one); aria-haspopup="dialog", aria-expanded and aria-controls are set in popover mode. Click, Enter and Space are intercepted in the capture phase (no app handler, navigation or submit runs); custom role=button controls get the same Enter / Space handling, text fields become readonly and a select can’t open. Opening from the keyboard moves focus to the first way forward, opening with the pointer focuses the panel itself (a labelled non-modal dialog: title = what, description = why); the panel sits right after the control in the DOM, so Tab walks through it naturally, and the close button comes last. Escape closes it and returns focus to the control; tabbing out or clicking outside closes it without moving focus. The peek is aria-hidden (its text is already the description), appears on focus as well as hover, and Escape dismisses it. The ways forward are an ordered list labelled “What you can do”; each is a real button or link whose hint is its description. A request in flight sets aria-busy and aria-disabled on its row; success, failure, withdrawal, a decline and an allowed control are announced in polite or assertive live regions, and focus moves to Cancel request when the request row it was on is replaced by the status (and back to the path when withdrawn). The four answers are laid out as a description list. Refusals are never told by color alone: an eyebrow word (Not allowed, Not yet, or kind-label), an icon and the badge carry it; forced-colors mode keeps borders, badges and the recommended path visible. Reduced motion (OS or data-motion="reduce") stops the hourglass flip, the spinner and the success ring, and panels appear without sliding.