→ Template
Knock Codes Template
A complete, single-file restricted-access screen — dark backdrop, centered card, segmented code entry, support link, footer help text.
Best used for A polished, on-brand restricted-access page you can ship as-is, no assembly required.
The fastest way to a fully-branded, polished restricted-access page — copy one file, wire a hash and a logo, done. Paste a code across any box and it distributes automatically across the group.
- components
- knock-codes
- core
- react
"use client";
import { useEffect, useRef, useState, type KeyboardEvent, type ClipboardEvent, type ReactNode } from "react";
import { useKnockCodes } from "./useKnockCodes.ts";
import { DEFAULT_LABELS, type KnockCodesConfig, type KnockCodesLabels } from "./types.ts";
import { cx } from "./cx.ts";
export interface KnockCodesTemplateLabels extends KnockCodesLabels {
description?: string;
accessCodeLabel?: string;
supportLabel?: string;
footerText?: ReactNode;
}
export interface KnockCodesTemplateProps extends KnockCodesConfig {
children: ReactNode;
/** Rendered above the heading — your own logo/wordmark. Omitted entirely if not passed. */
logo?: ReactNode;
labels?: KnockCodesTemplateLabels;
/** Total code length, split into groups of `groupSize` with a dash between. @default 8 */
codeLength?: number;
/** @default 4 */
groupSize?: number;
/** 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 false to embed this somewhere other than a real page root (a demo, a docs preview) — drops the full-viewport (100dvh) sizing. @default true */
fullPage?: boolean;
/**
* Forces light or dark presentation on its own, independent of any
* ancestor ".dark" class — this is a single, standalone file, and
* shouldn't need the host app to already have dark-mode wiring (e.g.
* next-themes) in place just to show its dark variant. Omit to follow
* the nearest ".dark" ancestor if one happens to exist.
*/
theme?: "light" | "dark";
className?: string;
}
const TEMPLATE_LABELS: Required<KnockCodesTemplateLabels> = {
...DEFAULT_LABELS,
heading: "Restricted Access",
submitLabel: "Unlock Access",
description: "This feature is currently protected. Enter your access code to continue.",
accessCodeLabel: "Access code",
supportLabel: "Contact Support",
footerText: "Don't have an access code? Contact your administrator or support team for assistance.",
};
/**
* A complete, single-drop-in "restricted access" screen — full-page dark
* backdrop, centered card, segmented code entry, support link, and footer
* help text, all in one file. Built on the same `useKnockCodes`
* session/verification contract as every other block, just with a
* different presentation (segmented boxes instead of a masked text field —
* for that, use `<KnockCodes>` + `<PinInput>` instead).
*/
export function KnockCodesTemplate({
children,
logo,
labels,
codeLength = 8,
groupSize = 4,
supportHref,
onContactSupport,
fullPage = true,
theme,
className,
...config
}: KnockCodesTemplateProps) {
const merged = { ...TEMPLATE_LABELS, ...labels };
const { state, error, submit } = useKnockCodes(config);
const [digits, setDigits] = useState<string[]>(() => Array(codeLength).fill(""));
const inputRefs = useRef<Array<HTMLInputElement | null>>([]);
useEffect(() => {
if (state === "idle" && error) inputRefs.current[0]?.focus();
}, [error, state]);
if (state === "unlocked") return <>{children}</>;
const code = digits.join("");
const filled = code.length === codeLength && digits.every((d) => d !== "");
const setDigit = (index: number, value: string) => {
setDigits((current) => {
const next = [...current];
next[index] = value;
return next;
});
};
const handleSubmit = async () => {
if (!filled || state === "submitting") return;
await submit(code);
setDigits(Array(codeLength).fill(""));
inputRefs.current[0]?.focus();
};
const handleChange = (index: number, value: string) => {
const char = value.slice(-1);
setDigit(index, char);
if (char && index < codeLength - 1) inputRefs.current[index + 1]?.focus();
};
const handleKeyDown = (index: number, event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Backspace" && !digits[index] && index > 0) {
inputRefs.current[index - 1]?.focus();
setDigit(index - 1, "");
} else if (event.key === "ArrowLeft" && index > 0) {
inputRefs.current[index - 1]?.focus();
} else if (event.key === "ArrowRight" && index < codeLength - 1) {
inputRefs.current[index + 1]?.focus();
} else if (event.key === "Enter") {
void handleSubmit();
}
};
const handlePaste = (event: ClipboardEvent<HTMLInputElement>) => {
const pasted = event.clipboardData.getData("text").trim().slice(0, codeLength);
if (!pasted) return;
event.preventDefault();
const next = Array(codeLength).fill("");
for (let i = 0; i < pasted.length; i++) next[i] = pasted[i];
setDigits(next);
inputRefs.current[Math.min(pasted.length, codeLength - 1)]?.focus();
};
const errorMessage = error ? (error.reason === "network" ? merged.networkErrorMessage : merged.invalidErrorMessage) : null;
// Tailwind's `dark:` utilities only activate for descendants of a ".dark"
// ancestor — never for the element carrying that class itself — so
// forcing a theme needs this extra wrapper, not just a class on the root
// below. Omitting `theme` renders exactly as before (whatever ambient
// ".dark" ancestor happens to exist, or none).
const content = (
<div
className={cx(
"flex w-full items-center justify-center bg-gray-200 p-6 dark:bg-[#0b1220]",
// Real page-root usage wants the full viewport; embedded usage
// (a demo, a docs preview) wants to fill whatever height its own
// container was given instead — the container providing a real,
// definite height is that container's job, not this component's.
fullPage ? "min-h-[100dvh]" : "h-full",
className
)}
>
<div className="w-full max-w-[27rem] rounded-2xl bg-white p-8 shadow-2xl dark:bg-gray-950">
{logo && <div className="mb-6">{logo}</div>}
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">{merged.heading}</h1>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">{merged.description}</p>
<div className="mt-6">
<span className="mb-2 block text-xs font-medium tracking-wide text-gray-500 uppercase dark:text-gray-400">
{merged.accessCodeLabel}
</span>
<div role="group" aria-label={merged.accessCodeLabel} className="flex items-center gap-1.5">
{Array.from({ length: codeLength }, (_, index) => (
<div key={index} className="flex items-center gap-1.5">
{index > 0 && index % groupSize === 0 && <span className="text-gray-300">–</span>}
<input
ref={(el) => {
inputRefs.current[index] = el;
}}
value={digits[index]}
onChange={(event) => handleChange(index, event.target.value)}
onKeyDown={(event) => handleKeyDown(index, event)}
onPaste={handlePaste}
disabled={state === "submitting"}
maxLength={1}
autoComplete="off"
aria-label={`${merged.accessCodeLabel} character ${index + 1} of ${codeLength}`}
aria-invalid={error ? true : undefined}
className="h-11 w-10 rounded-lg border border-gray-300 text-center text-sm font-medium text-gray-900 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 focus:outline-none disabled:opacity-60 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-50"
/>
</div>
))}
</div>
<div role="status" aria-live="polite" className="mt-2 min-h-[1.1rem] text-xs text-red-600 dark:text-red-400">
{state === "submitting" ? merged.submittingLabel : (errorMessage ?? "")}
</div>
</div>
<button
type="button"
onClick={handleSubmit}
disabled={!filled || state === "submitting"}
className="mt-4 flex w-full items-center justify-center gap-2 rounded-full bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{state === "submitting" ? merged.submittingLabel : merged.submitLabel} →
</button>
{(supportHref || onContactSupport) && (
<div className="mt-3 text-center">
{onContactSupport ? (
<button
type="button"
onClick={onContactSupport}
className="text-sm font-medium text-red-600 hover:underline dark:text-red-400"
>
{merged.supportLabel}
</button>
) : (
<a href={supportHref} className="text-sm font-medium text-red-600 hover:underline dark:text-red-400">
{merged.supportLabel}
</a>
)}
</div>
)}
<hr className="my-5 border-gray-200 dark:border-gray-800" />
<p className="text-center text-xs text-gray-400 dark:text-gray-500">{merged.footerText}</p>
</div>
</div>
);
// A plain sized wrapper, not `display: contents` — `contents` has
// cross-browser quirks around passing percentage sizing through to
// children, which broke the fill-the-canvas behavior above.
return theme === "dark" ? <div className="dark h-full w-full">{content}</div> : content;
}
Generate a hash
Fully client-side — the plaintext code never leaves this page, and never touches a file. Only the hash below gets pasted into your project.
Add this template to your project
CLI
npx shadcn@latest add http://localhost:3000/r/react/knock-codes-template.jsonRunning this site somewhere other than localhost? Set NEXT_PUBLIC_SITE_URL and this command switches to that host automatically.
Also installs
- Knock Codes Core
- Knock Codes Types
- useKnockCodes
- cx (classname helper)
Files created (8)
- 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/cx.ts
- components/knock-codes/react/KnockCodesTemplate.tsx
No CLI? Copy 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.
→ Props
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. |
| children * | ReactNode | — | Rendered once unlocked. |
| logo | ReactNode | — | Rendered above the heading — your own logo/wordmark. Omitted entirely if not passed. |
| codeLength | number | 8 | Total number of code boxes. |
| groupSize | number | 4 | Boxes per group — a dash separates groups. |
| 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 | KnockCodesTemplateLabels | — | Overrides heading, description, access-code label, support label, footer text, and every KnockCodesLabels string. |
| fullPage | boolean | true | Set false to embed this somewhere other than a real page root (a demo, a docs preview) — drops the full-viewport (100dvh) sizing. |
| 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. |
| className | string | — | Extra classes on the full-page backdrop. |
Accessibility
Each code box has its own aria-label ("Access code character N of 8"), the group has role="group", and Backspace/Arrow-key navigation between boxes works without a mouse. Errors and the submitting state announce through a shared aria-live="polite" region, same contract as every other block.
Customization
Everything is one file — background, card, heading, description, code entry, button, support link, and footer are all inline, not composed from Gate Wrapper/PIN Input. Pass `logo` for your own wordmark, `codeLength`/`groupSize` to change the code shape, and `labels` to override every string. For a single masked field instead of segmented boxes, use Knock Codes + PIN Input instead.
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) stops determined visitors. This is a velvet rope, with an optional real lock — never marketed as more than that.
Need real protection?
Local mode is deterrence — the hash ships in your client bundle by design. Swap the expectedHash prop for a verify function pointing at one of these, and the code is checked server-side instead — same markup, same component, one prop different. Each template rate-limits attempts and returns a short-lived signed token on success.
// @ts-check
/**
* Knock Codes — Next.js route handler server verification template.
* Drop at app/api/verify-access/route.js (App Router).
*
* Set two env vars: KNOCK_CODES_SERVER_HASH, KNOCK_CODES_TOKEN_SECRET.
*
* Wire contract identical across all three server templates — see the
* Cloudflare Worker template in this same folder for the full contract.
*/
import { createHash, createHmac } from "node:crypto";
const RATE_LIMIT_MAX_ATTEMPTS = 5; // adjust per deployment
const RATE_LIMIT_WINDOW_SECONDS = 60;
const TOKEN_TTL_MS = 5 * 60_000; // 5 minutes
// In-memory counter — acceptable ONLY for a single-instance/dev deployment.
// Vercel and most production hosts run multiple instances, so this counter
// would not be shared across them; swap in Redis or Vercel KV (a single
// shared store, same key scheme below) before deploying for real.
/** @type {Map<string, number>} */
const attemptsByKey = new Map();
/** @param {string} input */
function sha256Hex(input) {
return createHash("sha256").update(input, "utf8").digest("hex");
}
/** @param {string} secret */
function signToken(secret) {
const payload = Buffer.from(JSON.stringify({ exp: Date.now() + TOKEN_TTL_MS })).toString("base64url");
const signature = createHmac("sha256", secret).update(payload).digest("base64url");
return `${payload}.${signature}`;
}
/** @param {string} identifier */
function checkRateLimit(identifier) {
const windowStart = Math.floor(Date.now() / 1000 / RATE_LIMIT_WINDOW_SECONDS);
const key = `${identifier}:${windowStart}`;
const attempts = attemptsByKey.get(key) ?? 0;
if (attempts >= RATE_LIMIT_MAX_ATTEMPTS) return false;
attemptsByKey.set(key, attempts + 1);
return true;
}
/** @param {Request} request */
export async function POST(request) {
const identifier = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "unknown";
if (!checkRateLimit(identifier)) {
return Response.json({ ok: false, reason: "network" }, { status: 429 });
}
/** @type {unknown} */
let code;
try {
({ code } = /** @type {{ code?: unknown }} */ (await request.json()));
} catch {
code = undefined;
}
if (typeof code !== "string") {
return Response.json({ ok: false, reason: "network" }, { status: 500 });
}
if (sha256Hex(code) !== process.env.KNOCK_CODES_SERVER_HASH) {
return Response.json({ ok: false, reason: "invalid" });
}
return Response.json({ ok: true, token: signToken(/** @type {string} */ (process.env.KNOCK_CODES_TOKEN_SECRET)) });
}→ Compose with
Blocks this template is built from
Drop down to these directly once you need more control than the single-file template gives you.