Modal Access
A copy-paste React access screen for gating one section of a live page — a blur-overlay dialog, content stays mounted behind it. One file, zero dependencies.
Best used for Gating one section of an already-public page without hiding the rest of it.
Minimal setup
<ModalAccessTemplate expectedHash={process.env.NEXT_PUBLIC_KNOCK_HASH}>
<Dashboard />
</ModalAccessTemplate>
- components
- knock-codes
- core
- react
// Modal Access Template v1.0.0
"use client";
import { useEffect, useState, type ReactNode } from "react";
import { useKnockCodes } from "./useKnockCodes.ts";
import { PinInput } from "./PinInput.tsx";
import { DEFAULT_LABELS, type KnockCodesConfig, type KnockCodesLabels } from "./types.ts";
import { cx } from "./cx.ts";
// Shakes the dialog on a failed attempt — inlined via a plain `<style>` tag
// (not a Tailwind config keyframe) so this file works standalone in a host
// project that has no matching keyframe of its own.
// Wrapped in the reduced-motion query rather than toggled from JS: under
// `prefers-reduced-motion: reduce` this keyframe name simply doesn't exist,
// so the `animation: modal-access-shake …` utility below resolves to no
// visual effect.
const MODAL_ACCESS_SHAKE_KEYFRAMES = `@media (prefers-reduced-motion: no-preference) {
@keyframes modal-access-shake {
10%, 90% { transform: translateX(-1px); }
20%, 80% { transform: translateX(2px); }
30%, 50%, 70% { transform: translateX(-4px); }
40%, 60% { transform: translateX(4px); }
}
}`;
export interface ModalAccessTemplateLabels extends KnockCodesLabels {
description?: string;
supportLabel?: string;
}
export interface ModalAccessTemplateProps extends KnockCodesConfig {
/**
* The section being gated. Unlike the other templates, this stays mounted
* (blurred and inert) behind the dialog while locked, instead of being
* swapped out entirely — for gating one section of an already-visible
* page rather than taking over the whole screen.
*/
children: ReactNode;
/** Rendered above the heading — your own logo/wordmark. Omitted entirely if not passed. */
logo?: ReactNode;
labels?: ModalAccessTemplateLabels;
/** Renders "Contact support" as a link. Ignored if `onContactSupport` is also set. */
supportHref?: string;
/** Renders "Contact support" as a button instead of a link. */
onContactSupport?: () => void;
/** Set true if this is the entire page, so the wrapper takes the full viewport height. @default false */
fullPage?: boolean;
/** Forces light or dark presentation, independent of any ancestor ".dark" class. Omit to follow one if it exists. */
theme?: "light" | "dark";
/**
* Persist the unlocked session across reloads within the same tab via
* sessionStorage. Not a security boundary — clearable from DevTools or a
* private window, same as any other client-side storage. @default undefined (off)
*/
remember?: "session";
autoFocus?: boolean;
className?: string;
}
const TEMPLATE_LABELS: Required<ModalAccessTemplateLabels> = {
...DEFAULT_LABELS,
heading: "This section is restricted",
description: "Enter your access code to view it.",
supportLabel: "Contact support",
};
function SuccessDialogBody() {
return (
<div className="flex flex-col items-center gap-2 py-2 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-100 text-green-600 dark:bg-green-500/10 dark:text-green-400">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} className="h-5 w-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="text-sm font-medium text-gray-700 dark:text-gray-200">Access granted</p>
</div>
);
}
/**
* Gates one section of an already-visible page: the content stays mounted
* and blurred behind a centered dialog instead of disappearing entirely.
* For a full-page takeover instead, use `<KnockCodesTemplate>` or
* `<MinimalAccessTemplate>`. Same `useKnockCodes` contract as every other
* block.
*/
export function ModalAccessTemplate({
children,
logo,
labels,
supportHref,
onContactSupport,
fullPage = false,
theme,
remember,
autoFocus = true,
className,
...config
}: ModalAccessTemplateProps) {
const merged = { ...TEMPLATE_LABELS, ...labels };
const { state, error, submit, ready } = useKnockCodes({
...config,
storage: remember === "session" ? "sessionStorage" : config.storage,
});
const [code, setCode] = useState("");
const [shakeSeed, setShakeSeed] = useState(0);
const [showChildren, setShowChildren] = useState(false);
const locked = state !== "unlocked";
// Keeps the dialog open with a success message for a beat after unlocking
// instead of an instant swap to the now-unblurred `children`.
const celebrating = state === "unlocked" && !showChildren;
const showOverlay = locked || celebrating;
useEffect(() => {
if (error) setShakeSeed((seed) => seed + 1);
}, [error]);
useEffect(() => {
if (state !== "unlocked") {
setShowChildren(false);
return;
}
const timer = setTimeout(() => setShowChildren(true), 550);
return () => clearTimeout(timer);
}, [state]);
if (!ready) return null;
const handleSubmit = async () => {
if (!code || state === "submitting") return;
await submit(code);
setCode("");
};
const content = (
<div className={cx("relative w-full", fullPage ? "min-h-[100dvh]" : "h-full", className)}>
<div aria-hidden={showOverlay} inert={showOverlay || undefined} className={cx(showOverlay && "pointer-events-none blur-sm select-none")}>
{children}
</div>
{showOverlay && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50 p-4 backdrop-blur-[1px]">
<div
key={shakeSeed}
role="dialog"
aria-modal="true"
aria-label={merged.heading}
style={{ fontFamily: "var(--ag-font, inherit)" }}
className={cx(
"w-full max-w-sm rounded-[var(--ag-radius,1rem)] bg-[var(--ag-card,#ffffff)] p-6 shadow-2xl dark:bg-[var(--ag-card-dark,#030712)]",
shakeSeed > 0 && !celebrating && "animate-[modal-access-shake_0.4s_ease-in-out]"
)}
>
<style>{MODAL_ACCESS_SHAKE_KEYFRAMES}</style>
{celebrating ? (
<SuccessDialogBody />
) : (
<>
{logo && <div className="mb-4">{logo}</div>}
<h1 className="text-lg font-semibold text-gray-900 dark:text-gray-50">{merged.heading}</h1>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{merged.description}</p>
<div className="mt-4">
<PinInput
value={code}
onChange={setCode}
onSubmit={() => void handleSubmit()}
submitting={state === "submitting"}
error={error}
labels={labels}
autoFocus={autoFocus}
/>
</div>
{(supportHref || onContactSupport) && (
<div className="mt-3 text-center">
{onContactSupport ? (
<button type="button" onClick={onContactSupport} className="text-xs font-medium text-[var(--ag-primary,#2563eb)] hover:underline dark:text-[var(--ag-primary-dark,#60a5fa)]">
{merged.supportLabel}
</button>
) : (
<a href={supportHref} className="text-xs font-medium text-[var(--ag-primary,#2563eb)] hover:underline dark:text-[var(--ag-primary-dark,#60a5fa)]">
{merged.supportLabel}
</a>
)}
</div>
)}
</>
)}
</div>
</div>
)}
</div>
);
return theme === "dark" ? <div className="dark h-full w-full">{content}</div> : content;
}
Add this template to your project
Recommended
npx shadcn@latest add @knock-codes/modal-access-templateAlso installs
- Knock Codes Core
- Knock Codes Types
- useKnockCodes
- cx (classname helper)
- PIN Input
These install together as one atomic unit — even a presentational or read-only piece needs the full verification stack (hook, types, core) behind it to actually run.
Files created (10)
- components/knock-codes/core/hash.ts
- components/knock-codes/core/verify.ts
- components/knock-codes/core/session.ts
- components/knock-codes/core/storage.ts
- components/knock-codes/react/types.ts
- components/knock-codes/react/useKnockCodes.ts
- components/knock-codes/react/KnockCodesContext.tsx
- components/knock-codes/react/cx.ts
- components/knock-codes/react/PinInput.tsx
- components/knock-codes/react/ModalAccessTemplate.tsx
Other ways
GitHub shorthand
npx shadcn@latest add trivedi-vatsal/knock-codes/modal-access-templateCopy the files by hand
- Open the Code tab in the preview above.
- Create each path listed below in your project and paste its contents in.
- Do the same for anything listed under “Also installs”, if present.
Installing via an AI agent?
Drop AGENTS.md into your project root — it instructs any coding agent to hash the code locally, write only the hash, and confirm the plaintext never touched a file. Thin pointer files exist for tools that read a different filename.
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
| expectedHash | string | — | SHA-256 hex hash to verify against, for local mode. |
| verify | VerifyFn | — | Custom async verification function, for server mode. |
| validateSession | (session: KnockCodesSession) => boolean | Promise<boolean> | — | Called when a session is read from storage. Return false or throw to reject it (server-mode token check). |
| children * | ReactNode | — | The gated section. Stays mounted, blurred, and inert while locked — rendered normally once unlocked. |
| logo | ReactNode | — | Rendered above the heading in the dialog — your own logo/wordmark. Omitted entirely if not passed. |
| supportHref | string | — | Renders "Contact support" as a link to this URL. |
| onContactSupport | () => void | — | Renders "Contact support" as a button instead of a link — e.g. to open a chat widget. |
| labels | ModalAccessTemplateLabels | — | Overrides heading, description, input label, support label, and every KnockCodesLabels string. |
| fullPage | boolean | false | Set true if this template gates the entire page rather than one section, so the wrapper takes the full viewport height. |
| theme | "light" | "dark" | — | Forces light or dark presentation on its own, independent of any ancestor ".dark" class. Omit to follow the nearest ".dark" ancestor if one happens to exist. |
| remember | "session" | — | Persists the unlocked session across reloads within the same tab via sessionStorage. Not a security boundary — see the security model. |
| className | string | — | Extra classes on the outer wrapper. |
Exactly one of expectedHash or verify is required.
Accessibility
The dialog carries role="dialog" and aria-modal="true"; the blurred content behind it is marked aria-hidden and inert while locked, so screen readers and keyboard focus skip it entirely. The code field autofocuses when the dialog opens. Errors and the submitting state announce through a shared aria-live="polite" region, same contract as every other block.
Customization
Everything is one file — backdrop, dialog, heading, description, field, button, and support link are all inline. Unlike the other templates, `children` renders even while locked (blurred and inert) instead of being swapped out, since the point is gating a section of a page that's otherwise visible. Pass `logo` and `labels` to customize; set `fullPage` if this is the whole page rather than one section of it.
Need a hash? Use the hash generator on Getting Started — computed locally, never sent anywhere.
The honest version
Knock Codes stops casual visitors, search engines, and forwarded links. Local mode does not stop anyone who opens DevTools — the hash ships in your client bundle by design. Server mode (swap one prop) hides the hash from the client; children you already bundled are still in the JavaScript, and a forged session works unless you wire validateSession. A velvet rope, with an optional real lock. Never marketed as more than that.
Blocks this template is built from
Drop down to these directly once you need more control than the single-file template gives you.
- Protected ModalA copy-paste React access screen for a paywall-style preview — content stays mounted and blurred behind a modal prompt. Zero dependencies.
- Protected CardA copy-paste React access screen for gating one card in a dashboard grid — blurred preview, inline unlock. Zero dependencies.
- Unlock DialogA copy-paste React unlock dialog for triggering an access-code prompt from a custom button or menu item. Zero dependencies.