New
Feature Gate
Locks premium content behind a glass overlay and provides a path to upgrade. Reuses surface and accent tokens; no new tokens required.
Inline gate
The inline variant blurs the locked content in place and overlays a glass CTA. Use this for feature sections within a larger screen — it preserves layout while signalling premium content.
Sheet paywall
The sheet variant opens a focused modal with feature highlights and a pricing CTA. Use this when the user explicitly triggers a locked action, such as tapping a locked nav item.
Anatomy
| Layer | Description | Token |
|---|---|---|
| Blur content | Children rendered at blur(4px) with pointer-events disabled | — |
| Glass surface | backdrop-filter blur(20px) saturate(160%) | --surface-glass |
| Specular edge | inset 0 1px 0 rgba(255,255,255,0.08) — hairline on top | — |
| Icon well | 44×44 rounded rect, accent-tinted background | --accent / 0.15 |
| Upgrade button | Solid accent fill, white label | --accent |
Usage guidelines
- Always show enough of the locked content to communicate value — don't hide it entirely.
- Keep the feature name concise (2–4 words). Put extra context in the description line.
- The upgrade CTA must always be visible and tappable, even on small screens.
- Apply
prefers-reduced-transparencyfallback: replacebackdrop-filterwith a solid--surfacebackground. - Do not gate core navigation or destructive/dangerous actions — only feature upsells.
Implementation
FeatureGate.tsx
tsx
"use client";
import { useState, ReactNode } from "react";
import { Lock, Sparkles } from "lucide-react";
interface FeatureGateProps {
feature: string;
isLocked?: boolean;
description?: string;
onUpgrade?: () => void;
children: ReactNode;
}
export function FeatureGate({
feature,
isLocked = true,
description,
onUpgrade,
children,
}: FeatureGateProps) {
if (!isLocked) return <>{children}</>;
return (
<div style={{ position: "relative", borderRadius: 12, overflow: "hidden" }}>
{/* Blurred content */}
<div style={{ filter: "blur(4px)", pointerEvents: "none", userSelect: "none" }}>
{children}
</div>
{/* Glass overlay */}
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 12,
backdropFilter: "blur(20px) saturate(160%)",
WebkitBackdropFilter: "blur(20px) saturate(160%)",
background: "rgb(var(--surface) / 0.7)",
// Specular top edge
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.08)",
borderRadius: 12,
}}
>
<div
style={{
width: 44,
height: 44,
borderRadius: 12,
background: "rgb(var(--accent) / 0.15)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "rgb(var(--accent))",
}}
>
<Lock size={20} />
</div>
<div style={{ textAlign: "center", maxWidth: 240 }}>
<p style={{ fontWeight: 600, fontSize: 14, marginBottom: 4 }}>
{feature}
</p>
{description && (
<p style={{ fontSize: 12, color: "rgb(var(--text-secondary))", lineHeight: 1.5 }}>
{description}
</p>
)}
</div>
<button
onClick={onUpgrade}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "8px 16px",
borderRadius: 8,
background: "rgb(var(--accent))",
color: "#fff",
fontSize: 13,
fontWeight: 600,
border: "none",
cursor: "pointer",
}}
>
<Sparkles size={14} />
Upgrade to Pro
</button>
</div>
</div>
);
}| Prop | Type | Default | Description |
|---|---|---|---|
feature | string | — | Human-readable name of the gated feature, shown in the upgrade prompt. |
isLocked | boolean | true | When false, renders children without any overlay. |
variant | "inline" | "sheet" | "inline" | Inline overlays the locked content; sheet opens a full paywall modal. |
onUpgrade | () => void | — | Callback fired when the user taps the upgrade CTA. |
description | string | — | Optional supporting text shown below the feature name in the overlay. |