Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,22 @@ const onLoad = () => {
return <HCaptcha ref={captchaRef} onLoad={onLoad} sitekey={sitekey} {...props} />;
```

#### 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
<div id="captcha-challenge" />
<HCaptcha
ref={captchaRef}
sitekey={sitekey}
size="invisible"
challenge-container="captcha-challenge"
/>
```

You control placement and any surrounding modal/backdrop. See [`demo_cc`](./demo_cc) for a dimmed modal with configurable dismissal.

### References

#### Props
Expand All @@ -183,6 +199,7 @@ return <HCaptcha ref={captchaRef} onLoad={onLoad} sitekey={sitekey} {...props} /
|`languageOverride`|String (ISO 639-2 code)|No|`auto`|hCaptcha auto-detects language via the user's browser. This overrides that to set a default UI language. See [language codes](https://hcaptcha.com/docs/languages).|
|`reCaptchaCompat`|Boolean|No|`true`|Disable drop-in replacement for reCAPTCHA with `false` to prevent hCaptcha from injecting into `window.grecaptcha`.|
|`id`|String|No|`random id`|Manually set the ID of the hCaptcha component. Make sure each hCaptcha component generated on a single page has its own unique ID when using this prop.|
|`challenge-container`|String (container ID) or HTMLElement|No|`-`|Render the puzzle inside this container instead of the default popup/shim.|
Comment thread
DSergiu marked this conversation as resolved.
|`apihost`|String|No|`-`|See enterprise docs.|
|`assethost`|String|No|`-`|See enterprise docs.|
|`endpoint`|String|No|`-`|See enterprise docs.|
Expand Down
29 changes: 29 additions & 0 deletions demo_cc/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Custom challenge container demo

From the repo root:

```sh
pnpm setup
pnpm --filter @hcaptcha-react/demo-cc start
```

Open http://127.0.0.1:9001. The default sitekey is hCaptcha's public demo key. Use `?sitekey=YOUR_PUBLIC_SITEKEY` for your own key.

[`ModalCaptchaAction`](./src/ModalCaptchaAction.tsx) mounts the custom container before rendering hCaptcha and awaits `execute({ async: true })`. `dismissOnBackdrop` defaults to `false`, blocking backdrop clicks and Escape. Set it to `true` to cancel via `resetCaptcha()`. The native modal blocks background interaction in either mode.

```jsx
<ModalCaptchaAction
sitekey={sitekey}
dismissOnBackdrop={false}
onVerified={async (token) => {
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.
26 changes: 26 additions & 0 deletions demo_cc/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
83 changes: 83 additions & 0 deletions demo_cc/src/ModalCaptchaAction.tsx
Original file line number Diff line number Diff line change
@@ -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<void>;
}

export default function ModalCaptchaAction({
sitekey,
dismissOnBackdrop = false,
onVerified,
}: Props) {
const captchaRef = useRef<HCaptcha>(null);
const dialogRef = useRef<HTMLDialogElement>(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 (
<>
<button disabled={!ready || busy} onClick={handleSubmit}>
{busy ? 'Working…' : 'Verify'}
</button>

<dialog
ref={dialogRef}
className="captcha-dialog"
aria-labelledby={titleId}
onClick={(event) => {
if (dismissOnBackdrop && event.target === event.currentTarget) {
captchaRef.current?.resetCaptcha();
}
}}
onCancel={(event) => {
event.preventDefault();
if (dismissOnBackdrop) captchaRef.current?.resetCaptcha();
}}
>
<div className="captcha-dialog-content">
<h2 id={titleId}>Complete verification</h2>
<div id={challengeId} />
</div>
</dialog>

<HCaptcha
ref={captchaRef}
sitekey={sitekey}
size="invisible"
challenge-container={challengeId}
onReady={() => setReady(true)}
/>

<p role="status">{message}</p>
</>
);
}
25 changes: 25 additions & 0 deletions demo_cc/src/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hCaptcha custom challenge container</title>
<style>
body { font: 16px sans-serif; margin: 32px; }
main { max-width: 640px; }
label { display: block; margin: 16px 0; }
.captcha-dialog {
border: 0;
padding: 0;
max-width: calc(100vw - 32px);
max-height: calc(100vh - 32px);
}
.captcha-dialog::backdrop { background: rgb(0 0 0 / 60%); }
.captcha-dialog-content { padding: 16px; }
.captcha-dialog-content h2 { margin: 0 0 16px; font-size: 18px; }
</style>
</head>
<body>
<div id="app"></div>
</body>
</html>
29 changes: 29 additions & 0 deletions demo_cc/src/index.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<main>
<h1>Custom challenge container</h1>
<p>The puzzle renders inside a dimmed modal. Background interaction is blocked.</p>
<label>
<input
type="checkbox"
checked={dismissOnBackdrop}
onChange={(event) => setDismissOnBackdrop(event.target.checked)}
/>
Allow backdrop clicks and Escape to dismiss
</label>
<ModalCaptchaAction sitekey={sitekey} dismissOnBackdrop={dismissOnBackdrop} />
<p>This frontend demo obtains a token. Verify it server-side before performing a protected action.</p>
</main>
);
}

createRoot(document.getElementById('app')).render(<App />);
16 changes: 16 additions & 0 deletions demo_cc/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
35 changes: 35 additions & 0 deletions demo_cc/webpack.config.js
Original file line number Diff line number Diff line change
@@ -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 },
};
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
37 changes: 37 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
packages:
- lib
- demo
- demo
- demo_cc
1 change: 1 addition & 0 deletions types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading