Setup
Host it yourself
Everything runs on infrastructure you control: no H5P server, no backend, no third-party service between your learners and your content. Install it on your site, with or without a bundler, or serve its player page from a domain of your own and frame it. Which one depends less on your tooling than on what lives on your site: an H5P package is JavaScript, and the player runs it on the origin that serves the frame.
Which setup
| Your site | Setup |
|---|---|
Needs the element's API — results with the learner's identity to your own backend, resume="host", cmi5, a file picker — and plays only packages your team made | Install, with a bundler or without one |
| A static site with no sessions, playing your own packages | Install: every request stays on your domain. Embed works too, at the cost of a second domain |
| Has signed-in users: an LMS, a school or company portal, anything with accounts or an admin area | Embed, from a player domain you run, or Install with every package untrusted |
| Plays packages you did not make: uploads, links from users or teachers, a marketplace | Install with untrusted on them, or Embed |
| Cannot host a file: a page builder, a hosted CMS, an LMS page you only edit | Embed, from a player domain your organisation runs |
When a site has signed-in users and needs the API, the safety rows win: install and
play every package untrusted, embed a player page you host on a domain of its
own and take results through the relay, or install the player only on an origin that holds
no session.
Installed, you get the element's whole API and every event on your page, and every package
runs as your site, unless you set untrusted. Then none of a package's own code
runs: it plays with the library pack's code, and its content is checked against each
library's field definitions before it is shown, as the H5P CMS plugins check an author's
upload, which makes it as contained as in an H5P plugin on the same site.
Packages you did not make
sets the two modes beside the plugins'. Embedded, a package runs on the player domain and never sees your page,
cookies, storage or APIs; results reach you as relayed xAPI, and storage is kept per
embedding site. In every setup the package is named by src — the element's
attribute, or ?src= on the embed page — and naming it loads and plays, the way
it does on <video>.
How it compares with h5p.com, the H5P plugins, Lumi and h5p-standalone, and when one of those is the better choice.
What has to be where, when installed
| File | Where it can live | Why |
|---|---|---|
h5p-player.jsthe element |
Anywhere: your bundle, your own site, or a CDN | It is an ordinary ES module. |
h5p-sw.jsthe Service Worker |
Your own origin | Browsers refuse to register a worker from another origin. It is the one file that cannot come from a CDN. |
@missing-elements/h5p-runtimethe H5P runtime |
Your bundle (the runtime property), a CDN via assets-base, or frame-assets/ next to the element |
Plain static files, loaded into the frame the worker generates. A package of its own because it is GPL-3.0; the player is MIT. |
@missing-elements/h5p-librariesthe library pack, for libraries="pack" |
Your bundle (the pack property), your own site via pack-base, or jsDelivr, the default |
Plain static files, one archive per library, of which a learner's browser downloads only those a package needs. |
There are no fixed paths. The worker is registered with the scope <its folder>/h5p/,
so it never claims a scope a worker of your own might hold, and the frame document is
generated by the worker rather than served as a file.
Install with a bundler
Vite, webpack 5 and Rollup. Install it, import it once, use the element.
npm i @missing-elements/h5p-offline-player @missing-elements/h5p-runtimepnpm add @missing-elements/h5p-offline-player @missing-elements/h5p-runtimeyarn add @missing-elements/h5p-offline-player @missing-elements/h5p-runtimeimport '@missing-elements/h5p-offline-player'
import { runtime } from '@missing-elements/h5p-runtime'
document.querySelector('h5p-player').runtime = runtime<h5p-player src="https://host.example/course.h5p"></h5p-player>
That is the whole setup. Both packages name their files with
new URL('./file', import.meta.url), which these bundlers recognise: they copy
the files into the build and rewrite the URLs, and the runtime's export hands the element
the addresses it ended up with. Already have a Service Worker on the site? Nothing changes.
Each registration has its own scope, and the two never see each other's requests.
In a framework the element is a tag like any other, in React, Vue, Svelte or Angular. The
imports have to run in the browser, since the element defines itself as it loads: where
pages are rendered on the server, as in Next.js, Nuxt, SvelteKit or Astro, import them where
only the browser runs code, such as an effect, onMount or an Astro
<script>. Vue and Angular want the tag declared as a custom element
(compilerOptions.isCustomElement, CUSTOM_ELEMENTS_SCHEMA).
On an AI site builder, one prompt can make this install. Lovable did, from
Embed an H5P file using https://github.com/missing-elements/h5p-offline-player
and the .h5p file uploaded in the chat:
the example it built.
Other builders have not been tried yet.
A bundler that does not follow that pattern, esbuild among them, leaves the files behind.
Then serve both packages' dist/ folders from static paths and say where they are:
<h5p-player src="…"
sw="/vendor/h5p-player/h5p-sw.js"
assets-base="/vendor/h5p-runtime/"></h5p-player>
Exports from h5p.com and h5p.org often carry only their content, without the libraries
that play it. libraries="pack" takes each library a package needs from the
library pack,
and plays the pack's newer version of any library a package carries. On its own it fetches
them from jsDelivr. To serve them from your own site, install the pack too and hand its
pack export to the element. The bundler emits each library as an asset, and a
learner's browser downloads only the ones a package needs.
npm i @missing-elements/h5p-librariespnpm add @missing-elements/h5p-librariesyarn add @missing-elements/h5p-librariesimport '@missing-elements/h5p-offline-player'
import { runtime } from '@missing-elements/h5p-runtime'
import { pack } from '@missing-elements/h5p-libraries'
const player = document.querySelector('h5p-player')
player.runtime = runtime
player.pack = pack<h5p-player src="https://host.example/course.h5p" libraries="pack"></h5p-player>
The libraries keep their own licences, mostly MIT. Copy the package's
libraries.txt, which lists them, onto your site with the build.
refresh-libraries="off" plays the libraries a package carries as exported,
and the pack then only supplies what is missing.
Install without a build step
From your own site. Copy the files onto the site, so that nothing a
learner's browser loads comes from anyone else's server; npm is only how you get them.
public/ stands for the folder your site is served from:
npm install @missing-elements/h5p-offline-player @missing-elements/h5p-runtime
cp -r node_modules/@missing-elements/h5p-offline-player/dist public/h5p-player
cp -r node_modules/@missing-elements/h5p-runtime/dist public/h5p-player/frame-assets<script type="module" src="/h5p-player/h5p-player.js"></script>
<h5p-player src="https://host.example/course.h5p"></h5p-player>
The element finds the Service Worker and the runtime beside its own script, so it needs no
other attribute, and the worker is always the element's own version. For
libraries="pack", copy the library pack too, with the list of its libraries'
licences, and name its folder:
npm install @missing-elements/h5p-libraries
cp -r node_modules/@missing-elements/h5p-libraries/parts public/h5p-libraries
cp node_modules/@missing-elements/h5p-libraries/libraries.txt public/h5p-libraries/<h5p-player src="https://host.example/course.h5p"
libraries="pack" pack-base="/h5p-libraries/"></h5p-player>To upgrade, delete the folders and copy them again.
From the CDN. Or load the element and the runtime from jsDelivr, and copy
only the one file that has to be yours. Every learner's browser then fetches the player
from jsDelivr, as it does the pack's libraries unless pack-base names a copy
of your own.
<script type="module"
src="https://cdn.jsdelivr.net/npm/@missing-elements/h5p-offline-player@1.6.0/dist/h5p-player.js"></script>
<h5p-player src="https://host.example/course.h5p" sw="/h5p-sw.js"
assets-base="https://cdn.jsdelivr.net/npm/@missing-elements/h5p-runtime@1.0.1/dist/"></h5p-player>
Download dist/h5p-sw.js from the same CDN path, place it on your site, and point
sw at it. The runtime loads from the CDN, from its own package, which
assets-base names. Putting the worker at the
root is safe: its scope becomes /h5p/, not /. The element warns in
the console when the worker and the element are different versions, so pin the version and copy the worker again whenever you change it.
Embed
Write the player page as a static site, deploy it to a domain of your own, and frame it;
the player runs on that origin, never on yours. Use a separate registrable domain —
h5p-player.example.net, not a subdomain of your site, which would receive its
cookies — and keep nothing else on it.
npx @missing-elements/h5p-embed@latest h5p-player
It needs Node 22 or later. Keep @latest: without it, npm on an older Node
quietly installs the last version that supported it, which writes a player from before 1.0.
The folder holds the page, the player, the H5P runtime and the library pack, and the
headers written for whatever serves it: a Caddyfile, an
nginx.conf and an .htaccess for a server of your own, and the
files Netlify, Cloudflare Pages and Vercel read. --packages limits
the hosts packages may come from, and --ancestors the sites that may frame it;
the package's README
has the rest. Then, on your site:
<iframe src="https://h5p-player.example.net/?src=https://alekswebnet.github.io/h5p/gh-normalized.h5p&xapi=https://your-site.example"
allow="fullscreen; microphone; camera" style="width: 100%; border: 0"></iframe>
<script src="https://h5p-player.example.net/resizer.js"></script>
The page sizes itself through H5P's own resizer protocol and relays xAPI statements to
your page on request. microphone and camera are for Audio
Recorder, Speak the Words and AR Scavenger: a frame from another site gets neither unless
the page allows it. The address also takes libraries,
preload=auto and the element's display options by their attribute names:
frame, copyright, export, icon,
fullscreen=off, reporting, activity-id and
custom-css, and lang, your page's language, for H5P's own
buttons and dialogs. The embed example frames this site's
own copy of the page, /embed, and shows the two listeners.
Requirements
- The page is served over
https://or fromlocalhost. Service Workers do not run onhttp://orfile://. h5p-sw.jsis on the page's own origin. With a bundler that is automatic.- The package's host sends CORS headers, or no browser can read the file. Without them the element reports
error: no-cors, and your page can offer a file picker instead. Rangesupport on the host is optional. With it a 300 MB package starts after a few kilobytes; without it the archive is downloaded once and played from the browser's cache, booting as soon as its libraries have arrived.- Licences: the player is MIT. The H5P core runtime it loads is GPL-3.0, which is why it is a package of its own,
@missing-elements/h5p-runtime, whosedist/carries itsLICENSE.txtandNOTICE.txt; keep them with it when you copy or serve it. The runtime's NOTICE.md has the full account.
Listening to it
Everything the player learns arrives as a DOM event on the element. Nothing is stored
unless the resume attribute is set, and then only the content's own saved
state, in the browser's storage on that device.
Every statement carries context.revision, a fingerprint of the package it
came from, and context.platform, the player's version, so a completion can be
matched to the build the learner played; player.revision holds the same value.
const player = document.querySelector('h5p-player')
player.addEventListener('xapi', (event) => send(event.detail.statement))
player.addEventListener('finished', (event) => console.log(event.detail.statement.result))
player.addEventListener('error', (event) => {
if (event.detail.code === 'no-cors') offerFilePicker()
})
// A file from disk, when the host has no CORS headers: read with slice(), never loaded whole.
input.addEventListener('change', () => { player.file = input.files[0] })Attributes
src | The package URL. Setting a new one loads; to reload the same one, remove it and set it again. |
auto-resize | The element follows the content's own height by default. off leaves the height to you, from CSS or the resize event; the height the element writes is an inline style, which would beat your rule. |
sw | Where the worker script is. Default: h5p-sw.js next to the element. Must be same-origin. |
jobs | Where the background worker script is, for a page whose Content Security Policy refuses blob: workers: copy dist/h5p-jobs.js beside h5p-sw.js and point this at it. Default: a blob: URL, then h5p-jobs.js next to the element. Must be same-origin. |
runtime | A property: the runtime export of @missing-elements/h5p-runtime, for a bundler to emit the files. Set it before src; it wins over assets-base. |
assets-base | The folder the runtime's dist/ is served from. Default: frame-assets/ next to the element. May be a CDN. |
libraries | Where a package's libraries come from: pack for @missing-elements/h5p-libraries, every H5P hub content type's libraries, one archive per library, of which only those a package needs are downloaded, from jsDelivr, and whose newer versions play in place of those a package carries; or the URL of a .h5p that carries library folders, which only fills gaps; or several separated by spaces, tried in order. Downloaded once and cached. Default: a package plays as it is, and one without its libraries is refused, with the missing libraries named. |
pack-base | The folder of your own copy of the pack's parts/, with its libraries.txt of licences beside it, for pack to come from instead of jsDelivr. With a bundler, set the pack property to the package's pack export instead. |
refresh-libraries | off plays the libraries a package carries as exported; pack then only fills gaps. |
preload | auto pulls large compressed media once the content is up, instead of when the learner presses play. |
untrusted | Plays a package you did not make as data: none of its own code runs. Its library folders are ignored and the libraries come from libraries, pack when that is not set; content/ serves only H5P's own file types; the frame's script policy names exact paths instead of your origin; the content is checked against each library's semantics.json, as the H5P CMS plugins check it. A package that needs its own copy of a library is refused, and the error's missingLibraries.carried says so. |
allow-origins | Extra origins the frame's Content Security Policy should permit, space separated: a tenant's video host, an in-house CDN. |
resume | Keep the content's own saved state — the slide reached, the answers so far, the position in a video — in the browser's storage on that device, and pick up there on the next load of the package. host keeps nothing on the device: the page gets a userdata event on every save and hands the state back through userData before the next load. Default: off, nothing is kept. Nothing is sent in either case. |
save-interval | Seconds between saves while the content runs, with resume. The content is also saved shortly after each completion and as the page closes. Default: 10; the H5P plugins use 30, which suits a host that sends each save to a server. |
The element's whole API is in docs/api.md; when something goes wrong, see Troubleshooting, and for a site with one Service Worker, single-worker hosts.