Member Gate Row
Feature row locked behind a plan
What it is
A single settings row for a feature that's gated behind a plan, a trial, or nothing at all. It composes A9 entity-row for the icon/title/description grid and fills the trailing slot with a tier badge and a switch, so a locked, trialable, and unlocked feature all render as the same row shape at three points in its lifecycle.
Why it matters
Every product on the reference board keeps its paid features in the settings list rather than pulling them out into a separate 'upgrade' page — Tripo's members-only rows, Lovable's tier badges, Spline Pro features, and CapCut's crown marks are all a row the user was already scanning, with one trailing element telling them what it costs. That's also why this pattern can't wait for a monetization-focused wave: it ships wherever settings ship, alongside `run-button`'s insufficient-credits state and `promo-card` in the sidebar, because a settings screen with silently-missing rows looks like a bug, not a paywall.
Observed in: Tripo members-only rows · Lovable tier badges · Spline Pro features · CapCut crown marks
Anatomy
- 1
member-gate-rowRoot wrapper. Carries data-state for locked/unlocked/trial-available/inline-upsell. - 2
member-gate-row-trailingGroups the tier or trial badge with the switch so they move as one unit. - 3
member-gate-row-tier-badgeShown whenever the row is gated (locked or its inline-upsell) — e.g. "Pro". - 4
member-gate-row-trial-badgeShown only in trial-available — e.g. "Free trial ×1". - 5
member-gate-row-upsellThe inline panel revealed under a locked row — never a dialog. - 6
member-gate-row-upsell-ctaThe upgrade action inside the revealed panel. - 7
member-gate-row-upsell-dismissCollapses the panel back to locked without upgrading.
How to use it
Reach for it for any feature toggle that a plan, trial, or credit balance gates — anywhere you'd otherwise render an entity-row with a plain switch. The component is fully controlled: you own `state` and move it from locked to inline-upsell in response to `onRequestUpgrade`, the same way promo-card's caller owns `dismissed`. Use `trial-available` instead of `locked` the moment a feature has a real, usable trial rather than being purely aspirational — the two read identically to a user unless the badge and the switch's behaviour actually differ.
Do
Keep a locked feature's row in the list with its tier badge, exactly where the unlocked row would be — never remove or hide it.
4K exportProLocked — requires Pro. Activating opens upgrade options.Reveal the upgrade pitch inline, under the row that triggered it, and let the user dismiss it back to locked without navigating away.
4K exportProLocked — requires Pro. Activating opens upgrade options.Give a real, working trial its own trial-available state and badge — don't leave it looking locked until the user has already spent it.
Voice cloningFree trial ×1Let a tier badge do the talking, in text, the same way sidebar-nav's tier badges do — it shows what exists rather than hiding it.
4K exportPro
Don't
Don't remove a gated row from the list, or swap it for a generic placeholder — that hides the feature instead of selling it.
?Some features are only available on paid plans.Don't convey "locked" with a padlock glyph alone — it has no accessible name of its own and disappears for anyone not looking right at it.
4K exportDon't rely on opacity or a disabled control to say "locked" — dimming alone reads as broken, not gated, and a disabled switch loses its accessible state along with its interactivity.
4K export
Accessibility
- Keyboard
- One tab stop in `locked`, `unlocked` and `trial-available`: the switch. `inline-upsell` is three — the switch, the upgrade CTA, then the dismiss button. The row itself is never focusable, because `entity-row` is composed without `onSelect` and stays a plain div.
- Space and Enter both activate the switch. The native checkbox contract would swallow Enter, but this is not a native checkbox — Base UI renders a span carrying role="switch" and handles both keys — and the gated switch routes both of them to the upsell rather than to a toggle. Measured in the KeyboardOrder story.
- A locked switch is deliberately neither `disabled` nor `readOnly`. It keeps its tab stop and stays activatable so the attempt can be reported; either attribute would swallow the press before `onRequestUpgrade` fires and would also drop the row's state out of the accessibility tree.
- Escape does nothing to the inline upsell. Collapsing it means tabbing to the dismiss button — the panel is revealed content, not a dialog, so it has no dismiss key.
- The tier and trial badges are static text in every state. The switch is the only thing the keyboard can reach in the trailing slot.
- Screen reader
- The switch is named by `aria-labelledby` pointing at the row's own title, so it announces as "{label}, switch, off". The title is wrapped in a span carrying a generated id, which means a non-string `label` still resolves — the name is computed from that element's text content rather than from a `typeof children === "string"` check, so the failure that bites A5 `filter-bar` cannot happen here.
- While gated, the switch also carries `aria-describedby` onto an sr-only sentence: "Locked — requires {tier}. Activating opens upgrade options." That sentence, not the badge and not the dimming, is what makes locked programmatic, and it is why a padlock glyph or an opacity change is not enough.
- `trial-available` has no equivalent description. "Free trial ×1" is loose text in the trailing slot, outside the switch's name and description, so a screen-reader user hears an ordinary switch and has to find the badge separately.
- A gated switch always reports `aria-checked="false"` and never changes. Activating it announces no state change at all — what announces is the upsell panel, which is `role="status"` and therefore read when it appears.
- The tier badge repeats the tier already inside the lock description, so a gated row says "Pro" twice. Cheap redundancy rather than a defect, but do not add a third copy.
- Focus
- Revealing the upsell moves focus nowhere. Focus stays on the switch that was activated and the panel is announced through its `role="status"` — right for a status region, but it does mean the CTA is two Tabs away rather than under the cursor.
- Dismissing the upsell unmounts both of its buttons. If focus was on the dismiss button — which it is, for anyone who got there by keyboard — nothing restores it, so focus falls to `<body>` and the next Tab restarts from the top of the page. Move focus back to the switch inside your `onDismissUpsell`.
- The switch ships the vendored primitive's `focus-visible` ring and the two upsell buttons ship the standard Button ring, so nothing here depends on a focus style you have to supply.
Watch out for
- Opening a modal or navigating to a billing page from onRequestUpgrade. The spec is explicit that toggling a locked row reveals an inline upsell — wire it to flip
stateto inline-upsell in place, the same row stays mounted. - Forgetting the switch in a locked row is still a real, focusable control — it has to stay clickable so it can report the activation attempt. Don't reach for the native
disabledattribute or Base UI'sreadOnlyswitch prop; either one silently swallows the click beforeonRequestUpgradeever fires. - Treating
checkedas authoritative in a locked row. The row always renders its switch off regardless of what's passed in — the boolean only matters oncestateisunlockedortrial-available. - Pairing text-muted-foreground with the upsell panel's bg-muted/30 surface for the description copy. Use text-foreground/70, the same fix promo-card uses on its own tinted surfaces — the muted/muted pairing measures under 4.5:1.
Installation
npx shadcn@latest add https://super-ai-components.vercel.app/r/member-gate-row.jsonRequires a shadcn app on Base UI (style base-nova). Radix-based styles such as new-york fail to typecheck: these components use Base UI's render prop and callbacks like onOpenChangeComplete, which Radix primitives do not have.