Monorepo = Typeform public embed SDK. Puts typeform (form at
https://form.typeform.com/to/<form-id>) into host page as <iframe>, wires to page.
Ships:
@typeform/embed(packages/embed) — vanilla TypeScript. Published to npm, served as browser bundle athttps://embed.typeform.com/next/embed.js(exposeswindow.tf).@typeform/embed-react(packages/embed-react) — React components wrapping vanilla package. Pins exact@typeform/embedversion.demo-htmlanddemo-nextjs— private demo apps, also used by Playwright suites.
Owned by @Typeform/blocks. docs/ = source of public docs at
https://www.typeform.com/developers/embed/. Form itself (all inside iframe) lives in separate private repo; SDK owns only host-page side and messages exchanged with form.
Update this file in same PR when change any of:
- public exports:
packages/embed/src/index.ts,packages/embed-react/src/index.tsx - option types:
packages/embed/src/base/*.ts,packages/embed/src/factories/*/*-options.ts - HTML attribute allow-list:
packages/embed/src/initializers/build-options-from-attributes.ts - form ↔ SDK message protocol:
packages/embed/src/utils/build-iframe-src.ts,packages/embed/src/utils/create-iframe/ - release or CI workflows in
.github/workflows/
| Type | JS | HTML | React | CSS (npm) | Trigger |
|---|---|---|---|---|---|
| widget (inline) | createWidget(id, { container, … }) |
<div data-tf-widget="id"> |
<Widget id> |
css/widget.css |
none: renders at once, or on scroll with lazy |
| popup (centred modal) | createPopup(id, opts) |
<button data-tf-popup="id"> |
<PopupButton> |
css/popup.css |
JS: none, call open()/toggle(). HTML: the element |
| slider (side drawer) | createSlider(id, opts) |
<a data-tf-slider="id"> |
<SliderButton> |
css/slider.css |
same as popup |
| popover (floating bubble) | createPopover(id, opts) |
<div data-tf-popover="id"> |
<Popover> |
css/popover.css |
SDK renders own button |
| sidetab (tab on page edge) | createSidetab(id, opts) |
<div data-tf-sidetab="id"> |
<Sidetab> |
css/sidetab.css |
SDK renders own tab |
Every factory returns { unmount, refresh, focus }; four modal types add { open, close, toggle }.
id can be bare form id or full form URL. CSS paths under @typeform/embed/build/.
// npm, vanilla — the CSS is NOT injected for you
import { createWidget } from '@typeform/embed'
import '@typeform/embed/build/css/widget.css'
createWidget('<form-id>', { container: document.querySelector('#form') })<!-- CDN, no build step — the CSS is linked automatically from embed.typeform.com -->
<div data-tf-widget="<form-id>" data-tf-hidden="utm_source=site" data-tf-on-submit="onFormSubmit"></div>
<script src="//embed.typeform.com/next/embed.js"></script>// React — the CSS is inlined in the bundle, nothing to import
import { useCallback } from 'react'
import { PopupButton } from '@typeform/embed-react'
const Contact = () => {
const onSubmit = useCallback(({ responseId }) => {}, []) // stable identity, see gotchas
return (
<PopupButton id="<form-id>" onSubmit={onSubmit}>
Open
</PopupButton>
)
}Full reference: docs/configuration.md. TypeScript types = source of
truth: packages/embed/src/base/url-options.ts, behavioral-options.ts, iframe-options.ts and
packages/embed/src/factories/*/*-options.ts. Most important:
- Data in:
hidden(hidden fields declared in form; sent in URL hash),tracking(query string; can override SDK params),transitiveSearchParams(copy host-page query params;true= all),preselect(first key only),hubspot. - Where form lives:
domain(bare hostname, defaultform.typeform.com),region: 'eu',source/medium/mediumVersion(attribution for integrators and plugins). - Look:
hideHeaders,hideFooter,opacity(0–100),width/height(number = px),size(popup, %),position(slider),buttonColor/buttonText/customIcon/tooltip/notificationDays(popover, sidetab),buttonProps,iframeProps. - Widget behaviour:
autoResize(trueor'min,max'),lazy,autoFocus,inlineOnMobile,fullScreen,noScrollbars,disableScroll. - Modal behaviour:
open+openValue(load,timems,scroll%,exitpx),autoClose,keepSession,preventReopenOnClose,respectOpenModals. - Modes:
enableSandbox(Typeform test mode: no response stored,responseIdis'__sandbox'; not iframe sandbox),disableTracking,shareGaInstance,redirectTarget.
| Callback | Fires when | Payload |
|---|---|---|
onReady |
form loaded | { formId, isClosed, formTitle } |
onStarted |
respondent started answering | { formId, responseId } |
onQuestionChanged |
visible screen changed | { formId, ref } |
onHeightChanged |
form content height changed | { formId, ref, height } |
onSubmit |
response submitted | { formId, responseId } |
onEndingButtonClick |
ending button clicked; registering it disables its redirect | { formId, ref? } |
onDuplicateDetected |
form refused duplicate response | { formId } |
onClose |
modal closed (user, close(), Escape) |
none |
Modal embeds attach iframe on first open(), so onReady fires then, not at creation.
Messages matched by type and embed id only; no event.origin check. Treat callback data as UI hint, verify responses server-side (Responses API or webhooks).
- Name =
data-tf-+ option in kebab-case (autoResize→data-tf-auto-resize). Allow-list and parsers inpackages/embed/src/initializers/build-options-from-attributes.ts. - Booleans: present with
"",trueoryes→ true; any other value, including"false", → false. Omit attribute to leave option unset. - Objects =
k=v,k2=v2(not JSON), arrays =a,b,c; escape comma as\,. - Callbacks = name of function on
window. Top-levelconst/letfunctions not onwindow, fail silently. containerandregioncan't be set from HTML; usedata-tf-domain="form.typeform.eu".- Elements added after load:
window.tf.load()initialises new ones,window.tf.reload()re-initialises all.data-tf-live="<embed-id>"fetches snippet fromapi.typeform.com/single-embed/<embed-id>.
embed.typeform.com/embed.js(no/next) = legacy v0 library (typeformEmbed.makeWidget), not compatible with this one. Use/next/embed.jsandwindow.tf.create*.- npm vanilla package loads no CSS. Import
@typeform/embed/build/css/<type>.css. HTML API always links CSS fromembed.typeform.com/next/css/(baked in at build time, even on bundle pinned via unpkg). React inlines it as<style>, with nonce from__webpack_nonce__. - npm build is UMD/CJS only (
mainonly; noexportsormodulefield). Vite and SvelteKit users may needimport * as embed from '@typeform/embed'. - Next.js App Router: React package has no
'use client'; use from Client Component.createPopoverandcreateSidetabthrow on server; other factories no-op without DOM. - React re-creates embed when callback identity changes. Components memoised with deep equality, but functions compare by reference, so inline callbacks rebuild iframe on every parent render and respondent loses progress. Wrap in
useCallback. Ref prop isembedRef(notref);Widgethas none. - React 19 types: published
embed-reacttypings importReactHTML, removed by@types/react19, so type-check fails (TS2305) though runtime works. CI tests React 18 only. createPopupandcreateSliderrender no button. Callopen()/toggle(), or useopen: 'load' | 'time' | 'scroll' | 'exit'.hidden≠tracking. Hidden fields must exist in form and travel in URL hash;trackinggoes in query string.domain= bare host (form.typeform.eu), not URL.- EU accounts: forms live on
form.typeform.eu. Plain embed points atform.typeform.com, which doesn't serve EU forms (returns Typeform's generic landing page). Useregion: 'eu'(JS, React) ordata-tf-domain="form.typeform.eu"(HTML); customdomainwins overregion. Fordata-tf-live, adddata-tf-region="eu"on same element so snippet fetched fromapi.typeform.eu.regionrow indocs/configuration.mdstill says not available to customers yet; SDK supports it. enableSandbox= Typeform test mode, not iframesandbox. Iframe has nosandboxattribute, no defaulttitle(passiframeProps: { title }) andallow="microphone; camera"only;iframeProps.allowreplaces list, not extends.- Mobile widgets go fullscreen. On phone widget shows placeholder welcome screen, then opens as fullscreen modal unless
inlineOnMobileorfullScreenset. Check is user-agent based. - Redirects: default
redirectTargetis_parent(navigates host page)._selfkeeps redirect inside iframe (needed to chain typeforms);_blankblocked by Safari and Chrome Android;_self+autoClosecloses modal before redirect seen. onClosehas no payload. Code destructuring({ formId })from it throws.- Escape closes modal only while focus on host page. Form never posts
form-close, so Escape pressed inside iframe does nothing. unmount()removes DOM but notwindowmessagelisteners. Creating embeds in loop (e.g. every render) leaks listeners.- CSP: allow
frame-srcforform.typeform.com/form.typeform.eu(or yourdomain). HTML mode also needsscript-srcandstyle-srcforembed.typeform.com;data-tf-liveneedsconnect-srcforapi.typeform.com(api.typeform.eufor EU). Forms can only be framed fromhttps:,localhost,capacitor:andionic:. buttonText,tooltipandcustomIconwritten withinnerHTML. Never pass untrusted input.
buildIframeSrc(packages/embed/src/utils/build-iframe-src.ts) buildshttps://<domain>/to/<id>?typeform-embed=<type>&typeform-embed-id=<random>&typeform-source=…&typeform-medium=embed-sdk&…, adds option flags (embed-hide-footer,typeform-embed-auto-resize, …), then transitive params, thentracking.hiddenandpreselectgo in hash.typeform-embedvalues:embed-widget,popup-blank,popup-drawer,popup-popover,popup-side-panel.createIframe(packages/embed/src/utils/create-iframe/) creates iframe, registers onewindowmessagelistener per form event:form-ready,form-started,form-screen-changed,form-height-changed,form-submit,form-theme,thank-you-screen-button-click,redirect-after-submit/thank-you-screen-redirect,duplicate-detected,welcome-screen-hidden,form-close. Every message carriesembedId. Outbound:embed-focusandga-client-id.- Host-page chrome (overlay, close button, popover bubble, sidetab) = plain DOM built in
packages/embed/src/factories/*, styled bypackages/embed/src/css/*.scss(class prefixtf-v1-, z-index 10001, popups go full-size under 480px). packages/embed/src/initializers/scans[data-tf-*]elements, parses attributes into options, calls factories;packages/embed/src/browser.ts= CDN entry running this on load.packages/embed-reactwraps each factory in component that calls it fromuseEffect, unmounts on cleanup. Itssrc/cssis symlink to embed's build output.
Layout: packages/embed/src/{base,factories,initializers,live-embed,utils,css}, packages/embed/e2e/{functional,visual}
(Playwright, config in packages/embed/playwright.config.ts), packages/embed-react/src/{components,utils}, docs/ (public docs),
scripts/release.sh, .github/workflows/.
Node 24 in CI (engines still says ≥ 18), Yarn 1, Lerna 3 as task runner only.
yarn install # runs lerna bootstrap
yarn build # embed, then embed-react, then the Next.js demo; build embed before embed-react
yarn lint && yarn test # eslint --max-warnings=0 + prettier (incl. docs/), jest (~300 tests, ~10 s each)
cd packages/embed && yarn demo # watch build + demo-nextjs on :9090
cd packages/embed && yarn preview # serve build/embed.js on :9022 with a matching CSS URL
yarn playwright install chromium # once: the e2e tests use Playwright's pinned Chromium
yarn test:functional # Playwright; mostly a blank page instead of the form, only form-interacting tests need network
yarn test:visual # Playwright + VRT; without VRT_APIKEY it saves screenshots to e2e/visual/local-screenshots
yarn test:e2e:open # Playwright UI; CI runs the visual job only on PRs from this repo, not forksConventions: conventional commits enforced by commitlint with Jira scope,
fix(TU-1234): Sentence case subject (NOJIRA-123 for untracked work). Husky 4 (.huskyrc) builds and lints on commit, runs tests on push. Prettier: no semicolons, single quotes, 120 columns.
Release: push to main runs scripts/release.sh via release.yml: semantic-release for @typeform/embed (npm with provenance, plus GitHub Packages), then automatic feat: bump of embed-react's dependency (so every embed release also cuts embed-react minor), then embed-react. feat → minor, fix/perf/chore(deps) → patch, BREAKING CHANGE: footer → major; PRs squash-merged, so squashed commit message is what semantic-release reads. Typeform tooling also expects branches named
type/TU-1234_snake_case and lowercase PR titles, fix(TU-1234): lowercase subject. @typeform/embed-v* release triggers deploy-aws.yml, uploads embed.js and CSS to embed.typeform.com/next/ with internal jarvis tool. Pushes to main also dispatch Typeform/developers (docs) and Typeform/embed-demo. Internal-only: AWS and preview deploys, VRT server (tracking runs, approving baselines), ci-standard-checks.
packages/*/CHANGELOG.md are generated.
- Option touches four places: option type,
build-options-from-attributes.ts(HTML allow-list and parser),build-iframe-src.ts(query param) anddocs/configuration.md. React picks type up automatically. Add spec next to each file. typeform-embed*query params and message types = contract with form renderer. Adding or renaming one needs renderer change first; SDK can't add behaviour form doesn't support. Checked against renderer'sapps/louvre/src/client/embed-settings.json 2026-10-08: maps onlyembed-widget,embed-fullpage,popup-classic,popup-drawerandpopup-blankto embed mode, sopopup-popoverandpopup-side-panelget none; absentembed-opacitymeans opaque (100);embed-hide-footerhides arrows and progress but not OK button or "Powered by Typeform".yarn buildwithoutNODE_ENV=production= dev build withCSS_URL=./lib/css/and eval source maps. Never publish or compare bundle sizes from it.- Don't edit
packages/*/CHANGELOG.md,packages/demo-nextjs/public(symlink to demo-html) orpackages/embed-react/src/css(symlink to embed's build). - Visual tests load live hosted form
HLjqXS5W(19 tests,packages/embed/e2e/visual); renderer change can break baselines without SDK change. Baselines live on VRT server, not repo, new ones need approving there (new browser or test name shows asNo baseline). Run on Playwright's pinned Chromium, so baselines also change when Playwright bumped. Only two sidetab tests setdiffTolerancePercent(1%) because text anti-aliasing varies between runs; keep every other screenshot at 0%. - Functional tests serve blank page for form (
mockFormsinpackages/embed/e2e/support.ts) when only asserting iframe attributes. Test needingform-ready, form's keyboard focus or its content must use real form. ci-standard-checksrejects any PR touchingtsconfig.jsonwithoutallowUnreachableCode: falseandnoImplicitAny: true, even ine2e/tsconfig.json.docs/published verbatim to developer portal; keep front matter (nav_title,nav_order), runyarn docs-prettier.
- Public docs source: docs/ —
configuration.md,callbacks.md,hidden-fields.md,inline.md,modal.md,react.md,vanilla.md,mobile-apps.md,custom-embed.md - Option types:
packages/embed/src/base/,packages/embed/src/factories/*/*-options.ts - URL and message protocol:
packages/embed/src/utils/build-iframe-src.ts,packages/embed/src/utils/create-iframe/ - HTML API:
packages/embed/src/initializers/,packages/embed/src/utils/load-options-from-attributes.ts,packages/embed/src/browser.ts - React wrappers:
packages/embed-react/src/components/ - Live, editable examples: https://github.com/Typeform/embed-demo
- Bugs and feature requests: https://github.com/Typeform/embed/issues. Form-side problems (what renders inside iframe) go to Typeform support, not this repo.