diff --git a/README.md b/README.md
index 249b2f6..66fcd7d 100644
--- a/README.md
+++ b/README.md
@@ -170,6 +170,22 @@ const onLoad = () => {
return ;
```
+#### Custom challenge containers
+
+Pass `challenge-container` with a container ID or DOM element to render the puzzle inline instead of using hCaptcha's popup/shim. Mount the container before hCaptcha renders. Outside clicks leave the inline puzzle open; `execute({ async: true })` keeps waiting for its result.
+
+```jsx
+
+
+```
+
+You control placement and any surrounding modal/backdrop. See [`demo_cc`](./demo_cc) for a dimmed modal with configurable dismissal.
+
### References
#### Props
@@ -183,6 +199,7 @@ return {
+ const res = await fetch('/api/protected-action', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ token }),
+ });
+ if (!res.ok) throw new Error('Submission failed');
+ }}
+/>
+```
+
+The demo only obtains a token. Your backend must verify it through [siteverify](https://docs.hcaptcha.com/#verify-the-response-token-server-side) before performing the protected action. Keep the secret key on the server.
diff --git a/demo_cc/package.json b/demo_cc/package.json
new file mode 100644
index 0000000..bd4a455
--- /dev/null
+++ b/demo_cc/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "@hcaptcha-react/demo-cc",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "start": "webpack serve -c webpack.config.js",
+ "build": "webpack -c webpack.config.js",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@hcaptcha-react/lib": "workspace:^",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ },
+ "devDependencies": {
+ "@swc/core": "^1.15.8",
+ "@types/react": "^18.0.0",
+ "html-webpack-plugin": "^5.6.5",
+ "swc-loader": "^0.2.6",
+ "typescript": "^4.9.5",
+ "webpack": "^5.104.1",
+ "webpack-cli": "^6.0.1",
+ "webpack-dev-server": "^5.2.2"
+ }
+}
diff --git a/demo_cc/src/ModalCaptchaAction.tsx b/demo_cc/src/ModalCaptchaAction.tsx
new file mode 100644
index 0000000..3dba664
--- /dev/null
+++ b/demo_cc/src/ModalCaptchaAction.tsx
@@ -0,0 +1,83 @@
+import { useId, useRef, useState } from 'react';
+import HCaptcha from '@hcaptcha-react/lib';
+
+interface Props {
+ sitekey: string;
+ dismissOnBackdrop?: boolean;
+ onVerified?: (token: string) => Promise;
+}
+
+export default function ModalCaptchaAction({
+ sitekey,
+ dismissOnBackdrop = false,
+ onVerified,
+}: Props) {
+ const captchaRef = useRef(null);
+ const dialogRef = useRef(null);
+ const challengeId = useId();
+ const titleId = `${challengeId}-title`;
+ const [ready, setReady] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const [message, setMessage] = useState('');
+
+ async function handleSubmit() {
+ const captcha = captchaRef.current;
+ const dialog = dialogRef.current;
+ if (!captcha || !dialog || busy) return;
+
+ setBusy(true);
+ setMessage('');
+
+ try {
+ captcha.resetCaptcha();
+ dialog.showModal();
+ const { response: token } = await captcha.execute({ async: true });
+ dialog.close();
+ await onVerified?.(token);
+ setMessage('Verification completed.');
+ } catch {
+ setMessage('Verification cancelled or failed. Please retry.');
+ } finally {
+ dialog.close();
+ setBusy(false);
+ }
+ }
+
+ return (
+ <>
+
+ {busy ? 'Working…' : 'Verify'}
+
+
+ {
+ if (dismissOnBackdrop && event.target === event.currentTarget) {
+ captchaRef.current?.resetCaptcha();
+ }
+ }}
+ onCancel={(event) => {
+ event.preventDefault();
+ if (dismissOnBackdrop) captchaRef.current?.resetCaptcha();
+ }}
+ >
+
+
Complete verification
+
+
+
+
+ setReady(true)}
+ />
+
+ {message}
+ >
+ );
+}
diff --git a/demo_cc/src/index.html b/demo_cc/src/index.html
new file mode 100644
index 0000000..343be4f
--- /dev/null
+++ b/demo_cc/src/index.html
@@ -0,0 +1,25 @@
+
+
+
+
+
+ hCaptcha custom challenge container
+
+
+
+
+
+
diff --git a/demo_cc/src/index.jsx b/demo_cc/src/index.jsx
new file mode 100644
index 0000000..06cd13f
--- /dev/null
+++ b/demo_cc/src/index.jsx
@@ -0,0 +1,29 @@
+import React, { useState } from 'react';
+import { createRoot } from 'react-dom/client';
+import ModalCaptchaAction from './ModalCaptchaAction';
+
+const sitekey = new URLSearchParams(window.location.search).get('sitekey')
+ || 'a5f74b19-9e45-40e0-b45d-47ff91b7a6c2';
+
+function App() {
+ const [dismissOnBackdrop, setDismissOnBackdrop] = useState(false);
+
+ return (
+
+ Custom challenge container
+ The puzzle renders inside a dimmed modal. Background interaction is blocked.
+
+ setDismissOnBackdrop(event.target.checked)}
+ />
+ Allow backdrop clicks and Escape to dismiss
+
+
+ This frontend demo obtains a token. Verify it server-side before performing a protected action.
+
+ );
+}
+
+createRoot(document.getElementById('app')).render( );
diff --git a/demo_cc/tsconfig.json b/demo_cc/tsconfig.json
new file mode 100644
index 0000000..4c8f1ca
--- /dev/null
+++ b/demo_cc/tsconfig.json
@@ -0,0 +1,16 @@
+{
+ "compilerOptions": {
+ "strict": true,
+ "noEmit": true,
+ "skipLibCheck": true,
+ "jsx": "react-jsx",
+ "lib": ["ES2020", "DOM"],
+ "moduleResolution": "node",
+ "esModuleInterop": true,
+ "baseUrl": ".",
+ "paths": {
+ "@hcaptcha-react/lib": ["../types/index.d.ts"]
+ }
+ },
+ "include": ["src/*.tsx"]
+}
diff --git a/demo_cc/webpack.config.js b/demo_cc/webpack.config.js
new file mode 100644
index 0000000..a887e5c
--- /dev/null
+++ b/demo_cc/webpack.config.js
@@ -0,0 +1,35 @@
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import HtmlWebpackPlugin from 'html-webpack-plugin';
+
+const root = dirname(fileURLToPath(import.meta.url));
+
+export default {
+ mode: 'development',
+ entry: resolve(root, 'src/index.jsx'),
+ output: { path: resolve(root, 'dist'), filename: 'demo.js', clean: true },
+ resolve: {
+ extensions: ['.js', '.jsx', '.ts', '.tsx'],
+ alias: {
+ react: resolve(root, 'node_modules/react'),
+ 'react-dom': resolve(root, 'node_modules/react-dom'),
+ },
+ },
+ module: {
+ rules: [{
+ test: /\.[jt]sx?$/,
+ exclude: /node_modules/,
+ use: {
+ loader: 'swc-loader',
+ options: {
+ jsc: {
+ parser: { syntax: 'typescript', tsx: true },
+ transform: { react: { runtime: 'automatic' } },
+ },
+ },
+ },
+ }],
+ },
+ plugins: [new HtmlWebpackPlugin({ template: resolve(root, 'src/index.html') })],
+ devServer: { host: '127.0.0.1', port: 9001 },
+};
diff --git a/package.json b/package.json
index d743277..cf3dbcb 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "@hcaptcha/react-hcaptcha",
"description": "A React library for hCaptcha",
- "version": "2.2.0",
+ "version": "2.2.1",
"author": "hCaptcha team and contributors",
"license": "MIT",
"repository": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index bb0815a..4eb7909 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -49,6 +49,43 @@ importers:
specifier: ^5.2.2
version: 5.2.4(webpack-cli@6.0.1)(webpack@5.104.1)
+ demo_cc:
+ dependencies:
+ '@hcaptcha-react/lib':
+ specifier: workspace:^
+ version: link:../lib
+ react:
+ specifier: ^19.0.0
+ version: 19.2.3
+ react-dom:
+ specifier: ^19.0.0
+ version: 19.2.3(react@19.2.3)
+ devDependencies:
+ '@swc/core':
+ specifier: ^1.15.8
+ version: 1.15.8
+ '@types/react':
+ specifier: ^18.0.0
+ version: 18.3.27
+ html-webpack-plugin:
+ specifier: ^5.6.5
+ version: 5.6.5(webpack@5.104.1)
+ swc-loader:
+ specifier: ^0.2.6
+ version: 0.2.6(@swc/core@1.15.8)(webpack@5.104.1)
+ typescript:
+ specifier: ^4.9.5
+ version: 4.9.5
+ webpack:
+ specifier: ^5.104.1
+ version: 5.104.1(@swc/core@1.15.8)(webpack-cli@6.0.1)
+ webpack-cli:
+ specifier: ^6.0.1
+ version: 6.0.1(webpack-dev-server@5.2.4)(webpack@5.104.1)
+ webpack-dev-server:
+ specifier: ^5.2.2
+ version: 5.2.4(webpack-cli@6.0.1)(webpack@5.104.1)
+
lib:
dependencies:
'@hcaptcha/loader':
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 6840add..8459def 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,3 +1,4 @@
packages:
- lib
- - demo
\ No newline at end of file
+ - demo
+ - demo_cc
diff --git a/types/index.d.ts b/types/index.d.ts
index a898e14..6963d57 100644
--- a/types/index.d.ts
+++ b/types/index.d.ts
@@ -27,6 +27,7 @@ export interface HCaptchaProps {
theme?: "light" | "dark" | "contrast" | object;
tabIndex?: number;
id?: string;
+ "challenge-container"?: HTMLElement | string;
reCaptchaCompat?: boolean;
loadAsync?: boolean;
scriptLocation?: HTMLElement | null;