@jmeirinkmarimed/form-popup
v1.0.1
Published
A React email capture modal with HubSpot submission and web-component support
Maintainers
Readme
Form Popup
Email-capture modal for React apps and plain HTML sites. Ships as a React component and as a <form-popup> custom element (IIFE for static pages). Submits to HubSpot, then shows a confirmation screen. After a successful submit, the popup stays dismissed on later visits via localStorage.
Features
- Form screen, then confirmation screen after submit
- Persists completion in
localStorage(formPopupSubmitted:<formId>) so returning visitors do not see it again - Maps email, name, phone, state, and one extra property to HubSpot field names of your choosing
- Four form layouts: email + state, email only, name + email, email + phone
- Optional GDPR processing consent and HubSpot communication/subscription checkboxes
- Optional invisible Google reCAPTCHA (v2) before HubSpot submit
- Customizable copy, state list, colors, and fonts
- Style isolation via Shadow DOM on the web-component path
Install
npm install @jmeirinkmarimed/form-popupPeer dependency: React 17 or 18.
React
import { FormPopup } from '@jmeirinkmarimed/form-popup';
function App() {
return (
<FormPopup
formType="emailState"
hubspotPortalId="YOUR_PORTAL_ID"
hubspotFormId="YOUR_FORM_GUID"
emailProperty="email"
stateProperty="state"
additionalPropertyName="signup_source"
additionalPropertyValue="homepage_popup"
consentToProcessText="I agree to allow Example Company to store and process my personal data."
formTitle="Sign up for 10% off your first purchase!"
formSubtitle="Enter your email to join our mailing list."
states={[
"Delaware",
"Maryland",
"Massachusetts",
"Illinois",
"Missouri",
"Just Browsing",
]}
confirmationTitle="Use code WELCOME10 for 10% off!"
confirmationMessage="You're officially on the list for sweet updates delivered straight to your inbox."
confirmationButtonText="Shop Betty's"
confirmationButtonUrl="https://shopbettys.com"
recaptchaSiteKey="YOUR_INVISIBLE_RECAPTCHA_SITE_KEY"
waitForAgeGate
/>
);
}HTML
<script src="https://unpkg.com/@jmeirinkmarimed/form-popup/dist/form-popup.min.js"></script>
<form-popup
form-type="emailState"
hubspot-portal-id="YOUR_PORTAL_ID"
hubspot-form-id="YOUR_FORM_GUID"
email-property="email"
state-property="state"
additional-property-name="signup_source"
additional-property-value="homepage_popup"
consent-to-process-text="I agree to allow Example Company to store and process my personal data."
communication-consents='[{"subscriptionTypeId":999,"text":"I agree to receive other communications from Example Company."}]'
form-title="Sign up for 10% off your first purchase!"
form-subtitle="Enter your email to join our mailing list."
states="Delaware,Maryland,Massachusetts,Illinois,Missouri,Just Browsing"
confirmation-title="Use code WELCOME10 for 10% off!"
confirmation-message="You're officially on the list for sweet updates delivered straight to your inbox."
confirmation-button-text="Shop Betty's"
confirmation-button-url="https://shopbettys.com"
recaptcha-site-key="YOUR_INVISIBLE_RECAPTCHA_SITE_KEY"
wait-for-age-gate="true"
></form-popup>Attribute names are kebab-case versions of the React props. states is a comma-separated list in HTML. communicationConsents is a JSON array in HTML.
Props / attributes
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| formType | "email" | "emailName" | "emailPhone" | "emailState" | "emailState" | Which fields to show |
| hubspotPortalId | string | — | HubSpot portal ID (required). Use "demo" to skip the API in local previews. |
| hubspotFormId | string | — | HubSpot form GUID fields are submitted to (required) |
| emailProperty | string | "email" | HubSpot property name for the email address |
| firstNameProperty | string | "firstname" | HubSpot property name for first name (emailName) |
| phoneProperty | string | "phone" | HubSpot property name for phone (emailPhone) |
| stateProperty | string | "state" | HubSpot property name for the selected state |
| additionalPropertyName | string | — | Extra HubSpot property name (for lists/segments) |
| additionalPropertyValue | string | — | Value sent for additionalPropertyName |
| consentToProcessText | string | — | GDPR processing-consent checkbox label. Omit to skip consent UI and legalConsentOptions |
| communicationConsents | CommunicationConsent[] | string | — | Optional marketing opt-ins. React: array. HTML: JSON array. Each item: subscriptionTypeId, text, optional required |
| formTitle | string | — | Form-screen headline (required) |
| formSubtitle | string | — | Form-screen supporting line |
| states | string[] | string | — | Radio options for emailState. React: array. HTML: comma-separated |
| submitButtonText | string | "Submit" | Form submit label |
| emailPlaceholder | string | "Email *" | Email input placeholder |
| firstNamePlaceholder | string | "First name *" | First name placeholder (emailName) |
| phonePlaceholder | string | "Phone *" | Phone placeholder (emailPhone) |
| stateLabel | string | "Select A State *" | State radio group label |
| confirmationTitle | string | — | Confirmation-screen headline (required) |
| confirmationMessage | string | — | Confirmation-screen supporting line |
| confirmationButtonText | string | — | Confirmation CTA label (hidden if omitted) |
| confirmationButtonUrl | string | — | Confirmation CTA destination |
| recaptchaText | string | "This site is protected by reCAPTCHA." | Footer disclaimer when Google reCAPTCHA is off. Hide with "" or "false". Ignored when recaptchaSiteKey is set (required branding is shown instead). |
| recaptchaSiteKey | string | — | Google reCAPTCHA v2 Invisible site key. Runs an invisible challenge before HubSpot submit |
| backgroundColor | string | #d4ecec | Modal card background |
| overlayColor | string | rgba(0, 0, 0, 0.55) | Page overlay |
| textColor | string | #111111 | Text color |
| buttonColor | string | #000000 | Button background |
| buttonTextColor | string | #ffffff | Button text |
| buttonHoverColor | string | #333333 | Button hover background |
| buttonHoverTextColor | string | #ffffff | Button hover text |
| fontFamily | string | system sans | Body / UI font stack |
| headingFontFamily | string | Georgia / serif | Headline and submit font stack |
| waitForAgeGate | boolean | false | Wait for @jmeirinkmarimed/age-gate: localStorage.ageVerified === "true" and #age-gate-modal gone |
| waitForStorageKey | string | — | Wait until this localStorage key equals waitForStorageValue |
| waitForStorageValue | string | "true" | Expected value for waitForStorageKey |
| waitUntilHidden | string | — | Wait until this selector matches no overlay (light DOM or <age-gate> shadow root) |
| waitUntilHiddenHost | string | "age-gate" | Shadow-host selector for waitUntilHidden |
Form types
| formType | Fields |
|------------|--------|
| emailState (default) | Email + state radios — original Betty's layout |
| email | Email only |
| emailName | First name + email |
| emailPhone | Email + phone |
states is only used for emailState. First name and phone HubSpot property names default to HubSpot's firstname and phone.
Behavior
- Closing the popup (X, overlay click, or Escape) hides it for the current page view only.
- After a successful HubSpot submit,
localStorageis set so the popup does not return on later visits. The confirmation screen still shows until the user closes it or follows the CTA. - Set
hubspotPortalId="demo"in local examples to skip the network call and still reach the confirmation screen. - HubSpot forms with a required GDPR processing checkbox need
consentToProcessText(copy the checkbox label from the form). Optional email-subscription checkboxes go incommunicationConsents, using each checkbox’s HubSpot subscription type ID. Leave both unset for forms with no legal-consent options. - Invisible Google reCAPTCHA: create a reCAPTCHA v2 Invisible key, pass it as
recaptchaSiteKey/recaptcha-site-key, and turn off HubSpot’s form CAPTCHA. The Forms API returnsFORM_HAS_RECAPTCHA_ENABLEDif HubSpot’s own CAPTCHA is on. The Google badge is hidden; required Privacy Policy and Terms links are shown in the footer. - Both this popup and
@jmeirinkmarimed/age-gateuse a very highz-index. Put<age-gate>on the page and setwait-for-age-gate="true"so the form waits untilageVerifiedis"true"and#age-gate-modalhas unmounted. Do not treat a missing overlay as done — the gate may not have rendered yet. Returning visitors already inlocalStoragesee the form immediately. Put<age-gate>before<form-popup>in the theme. When reCAPTCHA is on, the overlay sits just below Google’s challenge iframe so the challenge can appear on top.
Custom fonts
Import fonts in your app (or <link> / @font-face in HTML). Set headingFontFamily / fontFamily to match. The Betty's preview loads Alegreya for headlines.
Local development
This repo uses Bun only (bun.lock). Do not commit package-lock.json.
bun install
bun run dev # playground → http://localhost:5173
bun run build # dist/ (ES + CJS + UMD + IIFE + .d.ts)
bun run examples:serve # previews :8080, playground :5173, react-consumer :5174
bun run lintHTML previews live in examples/previews/ and load a locally built form-popup.min.js (gitignored; created by examples:serve). Customize a preview here, then copy the <form-popup> markup onto the live site.
Publishing
bun run build produces:
| File | Purpose |
|------|---------|
| dist/form-popup.es.js | ESM (React peer externals) |
| dist/form-popup.cjs.js | CommonJS |
| dist/form-popup.umd.js | UMD |
| dist/form-popup.min.js | Standalone IIFE (React bundled) for <script> / unpkg |
| dist/index.d.ts | TypeScript declarations |
Importing the package registers <form-popup> as a side effect. The IIFE build also exposes window.React / window.ReactDOM.
See CONTRIBUTING.md for contribution notes.
