Headless consent & terms modal for React. Enforces scroll reading across multiple documents and generates a timestamped audit payload on acceptance. Works standalone or inside any form library.
npm install agree-firstReact 18+ and 19+ supported. Next.js App Router compatible.
agree-first ships two things:
- A modal — renders via
createPortalintodocument.body. No extra library required. Has its own markup, scroll tracking, progress bar, tab navigation (multi-document), focus trap, and keyboard handling. - A checkbox + submit button — a minimal default UI, entirely optional.
You import the CSS if you want the built-in style. If you prefer your own UI (shadcn, HeroUI, Tailwind, etc.) you skip the CSS and use the render prop — agree-first then only manages state and renders the modal.
┌─────────────────────────────────┐
agree-first │ State (hook) │
│ ├─ scroll tracking │
│ ├─ tab progress │
│ ├─ audit payload │
│ └─ localStorage persistence │
│ │
│ Modal (portal → document.body) │
│ ├─ focus trap │
│ ├─ Escape key │
│ ├─ progress bar │
│ └─ multi-document tabs │
└─────────────────────────────────┘
Import the default CSS (skip if using unstyled or render prop):
import "agree-first/styles";Default UI — no other library needed:
import { AgreeFirst } from "agree-first";
const DOCS = [
{
title: "Terms of Service",
content: <div>…</div>,
url: "/terms",
type: "terms",
version: "2.1.0",
},
{
title: "Privacy Policy",
content: <div>…</div>,
url: "/privacy",
type: "privacy",
version: "1.4.0",
},
];
export function SignupForm() {
return (
<AgreeFirst
documents={DOCS}
onAccept={(payload) => payload && saveConsent(payload)}
>
Create account
</AgreeFirst>
);
}Use any checkbox and button from your preferred UI library. agree-first only manages state and renders the modal:
import { AgreeFirst } from "agree-first";
import { Checkbox, Button } from "your-ui-library";
<AgreeFirst
documents={DOCS}
onAccept={(payload) => payload && saveConsent(payload)}
render={({ isAccepted, openModal, canSubmit, submit }) => (
<div>
<Checkbox
checked={isAccepted}
onChange={(checked) => { if (checked && !isAccepted) openModal(); }}
label="I have read and agree to the Terms of Service and Privacy Policy"
/>
<Button disabled={!canSubmit} onClick={submit}>
Create account
</Button>
</div>
)}
/>agree-first works as a controlled field in React Hook Form, Formik, TanStack Form, and react-final-form via value / onChange / onBlur / name props.
import { useForm, Controller } from "react-hook-form";
const { control, handleSubmit } = useForm({ defaultValues: { terms: false } });
<Controller
name="terms"
control={control}
rules={{ validate: (v) => v || "You must accept the terms to continue" }}
render={({ field: { value, onChange, onBlur, name, ref } }) => (
<AgreeFirst
ref={ref}
name={name}
value={value}
onChange={onChange}
onBlur={onBlur}
documents={DOCS}
/>
)}
/>import { useField } from "formik";
function ConsentField({ name }: { name: string }) {
const [field, , helpers] = useField<boolean>(name);
return (
<AgreeFirst
name={name}
value={field.value}
onChange={(accepted) => helpers.setValue(accepted)}
onBlur={() => helpers.setTouched(true)}
documents={DOCS}
/>
);
}<form.Field
name="terms"
validators={{ onChange: ({ value }) => !value ? "Required" : undefined }}
>
{(field) => (
<AgreeFirst
name={field.name}
value={field.state.value}
onChange={field.handleChange}
onBlur={field.handleBlur}
documents={DOCS}
/>
)}
</form.Field>import { Field } from "react-final-form";
<Field name="terms" type="checkbox">
{({ input }) => (
<AgreeFirst
name={input.name}
value={!!input.checked}
onChange={(accepted) => input.onChange(accepted)}
onBlur={input.onBlur}
documents={DOCS}
/>
)}
</Field>When value changes from true to false (e.g. form.reset()), agree-first automatically resets internal modal progress — the user will need to read and accept again.
Pass the user's last accepted payload and agree-first auto-accepts if nothing changed, or sets needsReAcceptance = true if a document version was updated:
const savedPayload = await db.getLastConsent(userId);
<AgreeFirst
documents={DOCS}
previousPayload={savedPayload}
render={({ needsReAcceptance, isAccepted, openModal, submit, canSubmit }) => (
<>
{needsReAcceptance && (
<p>Our Terms have been updated. Please review and accept again.</p>
)}
<Checkbox checked={isAccepted} onChange={() => openModal()} />
<Button disabled={!canSubmit} onClick={submit}>Continue</Button>
</>
)}
/>Skip the manual previousPayload management — storageKey reads and writes to localStorage automatically:
<AgreeFirst
documents={DOCS}
storageKey="my_app_consent_v1"
onAccept={(payload) => payload && saveToServer(payload)}
>
Continue
</AgreeFirst>On next visit, if document versions match, the user is auto-accepted. If a version changed, needsReAcceptance is true and the flow restarts.
Override all UI text in one object instead of passing individual string props:
<AgreeFirst
documents={DOCS}
strings={{
acceptText: "Eu Aceito",
continueText: "Aceitar e Continuar →",
scrollHint: "↓ Role até o final para continuar",
readFullText: "Ler documento completo",
modalTitle: "Termos e Privacidade",
closeText: "Fechar",
}}
>
Criar conta
</AgreeFirst>Individual props (acceptText, scrollHint, etc.) still work — strings takes precedence when both are provided.
Require the user to spend at least N milliseconds on a document before the accept button unlocks (in addition to, or instead of, scroll enforcement):
const DOCS = [
{
title: "Terms of Service",
content: <div>…</div>,
url: "/terms",
minReadTimeMs: 15_000, // 15 seconds
},
];
<AgreeFirst documents={DOCS} requireScroll={false}>
Continue
</AgreeFirst>| Prop | Type | Default | Description |
|---|---|---|---|
documents |
AgreeFirstDocument[] |
— | Required. One or more documents to present. |
children |
ReactNode |
— | Submit button label (default UI). Omit to hide the button. |
onAccept |
(payload?: AcceptPayload) => void |
— | Called on submit. Receives the full audit payload. |
onOpen |
() => void |
— | Called when the modal opens. Use for analytics. |
render |
(props: RenderProps) => ReactNode |
— | Headless mode — bring your own checkbox and button. |
consentId |
string |
auto-generated | Custom ID stored in the payload. |
label |
ReactNode |
auto-generated | Custom checkbox label text (default UI). |
requireScroll |
boolean |
true |
Lock the accept button until the user scrolls to the bottom. |
requireCheckbox |
boolean |
true |
Show the checkbox and lock submit until it is checked. |
modalTitle |
string |
document title | Modal heading. |
acceptText |
string |
"I Accept" |
Accept button label on the last tab. |
continueText |
string |
"Accept & Continue →" |
Accept button label between tabs. |
scrollHint |
string |
"↓ Scroll to the bottom to continue" |
Hint shown before scroll completes. |
readFullText |
string |
"Read full document" |
Footer link label. |
strings |
AgreeFirstStrings |
— | Override all text labels at once (i18n). Takes precedence over individual string props. |
previousPayload |
AcceptPayload |
— | Last accepted payload. Enables version detection and auto-accept. |
storageKey |
string |
— | localStorage key for automatic persistence. |
closeOnOverlayClick |
boolean |
true |
Set to false to prevent closing by clicking the backdrop. |
onDecline |
() => void |
— | Called when the modal is closed without full acceptance. |
onScrollProgress |
(progress: number) => void |
— | Fires as documents are scrolled. progress is 0–1 across all docs. |
value |
boolean |
— | Controlled accepted state (form library integration). |
onChange |
(accepted: boolean) => void |
— | Fires true on accept, false on reset. |
onBlur |
() => void |
— | Fires when the modal closes (any reason). Marks field as touched. |
name |
string |
— | Field name forwarded to a hidden input (native forms + RHF focus). |
unstyled |
boolean |
false |
Remove all default classes. Apply your own via classNames. |
className |
string |
— | Extra class on the outer container (default UI). |
classNames |
AgreeFirstClassNames |
— | Override individual element classes. |
interface AgreeFirstDocument {
title: string;
content: ReactNode;
url: string;
version?: string; // used for version detection
type?: "terms" | "privacy" | "policy" | "custom";
minReadTimeMs?: number; // minimum time on this tab (ms)
}Passed to the render function:
interface RenderProps {
isAccepted: boolean; // true after all documents accepted in the modal
isModalOpen: boolean; // true while the modal is visible
openModal: () => void; // open the modal
closeModal: () => void; // close the modal without accepting (fires onDecline if not yet accepted)
canSubmit: boolean; // true when isAccepted — safe to enable your submit button
submit: () => void; // call on form submit — triggers onAccept
reset: () => void; // reset all state (use for "re-read" flows)
scrollProgress: number; // 0–1, fraction of documents fully scrolled
needsReAcceptance: boolean; // true when previousPayload/storageKey version is outdated
getPayload: () => AcceptPayload | null; // read the payload without calling onAccept
}Returned by onAccept and getPayload():
interface AcceptPayload {
id: string; // consentId or auto-generated "c_<timestamp>_<random>"
timestamp: string; // ISO 8601
documents: Array<{
title: string;
url: string;
version?: string;
type?: string;
}>;
scrollCompleted: number[]; // indexes of documents scrolled to the bottom
userAgent?: string;
}Store this server-side for LGPD / GDPR audit trails.
Tracks whether a scrollable element has been scrolled to the bottom. Useful standalone — attach the returned containerRef to any scrollable div:
import { useScrollCompletion } from "agree-first";
function LegalText() {
const { hasScrolled, progress, containerRef } = useScrollCompletion();
return (
<>
<div ref={containerRef} style={{ height: 300, overflowY: "auto" }}>
{/* long content */}
</div>
<button disabled={!hasScrolled}>Continue</button>
</>
);
}| Return | Type | Description |
|---|---|---|
hasScrolled |
boolean |
true once the user scrolls within 10 px of the bottom |
progress |
number |
0–1 scroll fraction |
containerRef |
RefObject<HTMLDivElement> |
Attach to the scrollable element |
Use the underlying hook directly for full control:
import { useAgreeFirst } from "agree-first";
const {
isModalOpen, openModal, closeModal,
isAccepted, canSubmit, submit, reset,
getPayload,
activeTab, setActiveTab,
scrollCompleted, markScrollCompleted,
acceptTab, canAcceptCurrentTab,
scrollProgress, needsReAcceptance,
} = useAgreeFirst({
documents: DOCS,
requireScroll: true,
onAccept: (payload) => saveConsent(payload),
storageKey: "consent_v1",
onDecline: () => analytics.track("terms_dismissed"),
});Override the theme without touching classNames:
:root {
/* Colors */
--af-accent: #6366f1; /* primary color (buttons, links, progress) */
--af-accent-hover: #4f46e5;
--af-success: #10b981; /* "accepted" badge and tab check */
--af-bg: #ffffff; /* modal background */
--af-bg-subtle: #f3f4f6; /* progress bar track */
--af-bg-hover: #f3f4f6; /* hover states */
--af-text: #111827; /* headings */
--af-text-muted: #374151; /* body text */
--af-text-faint: #9ca3af; /* hints, inactive tabs */
--af-text-icon: #6b7280; /* close button icon */
--af-border: #f3f4f6;
--af-shadow: rgba(0, 0, 0, 0.20);
--af-overlay-bg: rgba(0, 0, 0, 0.50);
/* Typography */
--af-font-family: system-ui, -apple-system, sans-serif;
--af-font-size: 14px;
/* Modal sizing */
--af-modal-max-width: 720px; /* increase for wider layouts, e.g. 960px */
--af-modal-max-height: 90dvh;
--af-radius: 16px; /* border-radius of the modal card */
}Dark mode is handled automatically via prefers-color-scheme: dark. To override the dark tokens in an app that uses class-based theming, re-declare the variables under your dark selector:
.dark {
--af-accent: #818cf8;
--af-success: #34d399;
--af-bg: #1f2937;
--af-bg-subtle: #111827;
--af-bg-hover: #374151;
--af-text: #f9fafb;
--af-text-muted: #d1d5db;
--af-text-faint: #6b7280;
--af-text-icon: #9ca3af;
--af-border: #374151;
/* … */
}<AgreeFirst
classNames={{
overlay: "", modal: "", modalHeader: "",
modalTitle: "", modalClose: "", modalScrollArea: "",
modalFooter: "", modalAcceptRow: "", tabs: "",
tab: "", tabActive: "", tabDone: "",
progressBar: "", progressFill: "", acceptButton: "",
acceptBadge: "", docLink: "", scrollHint: "",
container: "", checkboxWrapper: "", checkbox: "",
label: "", labelLink: "", button: "",
}}
/>import { AgreeFirst } from "agree-first";
// no CSS import
<AgreeFirst unstyled classNames={{ modal: "my-modal", … }}>
Continue
</AgreeFirst>- Focus trapped inside the modal while open
Escapecloses the modal and returns focus to the trigger element- Tab order cycles within the modal
- Multi-document tabs use
role="tablist"/role="tab"/role="tabpanel" - Progress bar uses
role="progressbar"witharia-valuenow - Live region announces tab changes to screen readers
- All interactive elements have
:focus-visibleoutlines - Keyboard scroll when the document area is focused:
Space/PageDownscroll down,PageUpscrolls up,ArrowDown/ArrowUpscroll by line,Endjumps to bottom,Homejumps to top
MIT © Felipe Cepluki