@sekyro/react
React components: <LivenessCapture> and the full <SekyroVerification> flow.
React component for Sekyro AI liveness capture: the hosted page's
capture UI (<LivenessCapture />), extracted onto the public @sekyro/web API with
no private design-system or framework dependency. Themeable through CSS variables,
ar / fr / en built in, RTL-safe (logical CSS only), WCAG AAA defaults.
Status:
0.1.0-rc.0, stillprivate— the public npm release is wired in a later task (Changesets + Trusted Publishing). Sekyro AI's own hosted page dogfoods it via afile:link.
Install
npm install @sekyro/react @sekyro/web react react-domPeer dependencies: react >=18.2, react-dom >=18.2, @sekyro/web ^0.1.0-rc.8 || ^0.2.0-rc.0.
No other runtime dependencies.
Usage
import { createVerification } from "@sekyro/web";
import { LivenessCapture, SekyroThemeProvider } from "@sekyro/react";
import "@sekyro/react/styles.css";
const verification = createVerification({ clientToken, locale: "ar" });
<SekyroThemeProvider theme={{ colors: { brand: "#0c7a3d", ring: "#0c7a3d" }, radius: "8px" }}>
<LivenessCapture
verification={verification}
locale="ar"
onResult={(r) => console.log(r.status, r.reason)}
/>
</SekyroThemeProvider>- Verification mode (
verificationset): the handle comes fromverification.liveness(); aftercapturedthe SDK uploads, attests and submits theliveness+face_matchchecks and the verdict arrives viaonResult(approved | rejected | review | retry | unavailable). Onretrythe SDK re-arms the camera by itself (rearm: 'auto') while the component shows the reason, the retry coaching and the remaining attempts; withcreateVerification({ rearm: 'manual' })the component instead offers a focused "Start a new attempt" button. - The still freezes the stage the instant recording stops (the handle's
frozenevent) — before the clip finalizes; the "camera off" pill only appears once the tracks are actually stopped (captured). A LOW device profile (profileevent) drops the decorative animations. - Capture-only mode (no
verification):createLivenessCapture(); the component emitsonCaptured(artifacts)(keyframe + clip blobs) and offers a retake. You own the upload.
Props
| Prop | Type | Notes |
|---|---|---|
verification? | Verification | From createVerification(). Absent → capture-only. |
locale | "ar" | "fr" | "en" | Built-in dictionary. |
dir? | "ltr" | "rtl" | Defaults to rtl for ar. |
strings? | Partial<Strings> | Per-key overrides on top of the built-ins. |
slots? | { header, footer, intro(start, context), stageOverlay(videoRef), voicePrompt(hint, text, key, voice), errorPanel(err, retry) } | intro replaces the built-in pre-capture screen (see below); stageOverlay renders inside the stage box above the ring (overlays, debug HUDs); voicePrompt replaces the built-in voice chip in the stage's start corner; errorPanel replaces the camera-error notice. |
autoStart? | boolean | "when-permitted" | Skip the pre-capture screen and start once the detector preload is ready — at most once per mount. true arms it (flip it when your own preconditions are met); "when-permitted" arms it only when the camera permission is already granted (until the permission probe answers, the screen shows its loader). |
notice? | ReactNode | { tone, text } | Host message above the pre-capture screen (busy service, previous failure). Pre-capture only. |
startDisabled? / startLabel? | boolean / ReactNode | Disable the built-in Start and relabel it (e.g. a cooldown: "Retry in 12 s"). It gates only the pre-capture Start — not the error Retry, "record again" or the retake button. |
tips? | false | (BuiltInTipId | IntroTip)[] | Tips card; default DEFAULT_TIPS; false hides it. |
defaultFacing? | "user" | "environment" | Camera the attempt opens with (default front). |
voice? | false | VoiceOptions | Voice guidance; on by default. false = no speech, no switch, no chip. |
onGuidance? | (key) => void | Telemetry: once per change of the displayed fcv3.* guidance key. |
onStateChange? | (state) => void | Every capture-state transition, for host chrome (hide a description while the stage is live, telemetry). |
onCaptured? | (artifacts) => void | Fires before any upload. |
onResult? | (result) => void | Verification mode only. |
onError? | (error) => void | Camera / engine failure (permission | notfound | inuse | insecure | unsupported | generic). |
assetBaseUrl? | string | Detector assets base (default /models-v3). |
Pre-capture screen
While the capture is idle the component renders a built-in "get ready" screen:
a tips card, the camera-permission row (granted / prompt / denied with the
fix / unknown), a front/back choice on devices with both cameras, the voice
switch, the security note and one Start control gated on the detector preload
(preloadLivenessAssets from @sekyro/web, kicked on mount): a busy Start while
the models download, Start when ready, a danger notice + Retry if the download
failed. Without a camera API (SSR, old browsers) the preload is skipped and Start
is ready at once.
slots.intro(start, context) replaces the whole screen. context is
{ assets: "loading" | "ready" | "failed", retryAssets, permission, facing, setFacing, canChooseFacing, voice }; call start from your click handler — it
opens context.facing and unlocks audio inside the gesture (iOS). The notice
still renders above your intro.
<LivenessCapture
verification={verification}
locale="fr"
notice={busy ? { tone: "warning", text: t("serviceBusy") } : undefined}
startDisabled={cooldown > 0}
startLabel={cooldown > 0 ? t("retryIn", { s: cooldown }) : undefined}
tips={["light", "uncover", { id: "id-card", text: t("haveIdReady") }]}
/>Voice guidance
During capture the component speaks the displayed (debounced) guidance — the
fcv3.* gate hints — through one <audio> element, falling back to the
device voice (speechSynthesis). A stage chip (aria-pressed) and the intro
switch toggle it; the viewer's choice persists in localStorage.
VoiceOptions | Default | Notes |
|---|---|---|
defaultEnabled? | true | Used when the viewer has no stored preference. |
clips? | verification mode: Sekyro AI clips from the session (add https://assets.sekyro.ai to media-src); capture-only: none | VoiceClipSource: a resolver (key, locale) => url | null or a map { [locale]: { [voiceKey]: url } } (e.g. delivered by your backend — pass a new object to change it). Yours always wins over the Sekyro AI clips. A missing URL or a throwing resolver = no clip → device voice. |
speechFallback? | true | false, or the locales where the device voice may speak. A device with no voice for the language stays silent rather than read Arabic in a French voice. |
storageKey? | "sekyro.voiceGuidance" | null = do not persist. |
Sekyro AI clips. In verification mode the component speaks the recorded
ar/fr clip set that arrives with the session (verification.voiceClips()),
served from https://assets.sekyro.ai — a page with a Content-Security-Policy
must allow that origin in media-src. Until the set arrives, when the session
has none, or when it cannot be fetched, the device voice speaks. The package
ships no audio. VOICE_KEYS lists the nine fcv3.* guidance keys plus
capture.tips.summary.
"Listen to the instructions". The pre-capture screen has a button under
the tips that plays the spoken summary capture.tips.summary. It never
auto-plays, a second tap stops it, and Start stops it too. It shows only with
the built-in tips, unless your voice.clips has a capture.tips.summary clip
for the locale. It never shows with voice={false}, and the voice switch
does not hide it.
iOS. Audio unlocks inside a user gesture: the Start tap, or the voice
switch/chip turned on. With autoStart there is no Start tap, so audio stays
locked until the person's first activating touch, click or key press anywhere
inside the component (touchend / click / keydown other than Escape, or a
mouse/pen pointerup; a touch pointerdown is not a user activation on WebKit).
It unlocks once; an event without activation leaves the listener armed. The
clips are prefetched in that same gesture, and only while voice is on. Guidance
due before the unlock stays silent on iOS.
voicePrompt slot. It replaces the chip and receives the shared
VoiceControl ({ supported, enabled, setEnabled, toggle }) as its fourth
argument; the component keeps speaking. If your slot plays audio itself, pass
voice={false} or the person hears it twice. A development build warns once
when slots.voicePrompt is set and voice is undefined (pass voice={{}} to keep
the built-in voice and silence it).
CSP. Clips and the unlock use media-src 'self' blob: (the silent unlock WAV
is a blob: URL — no data: needed); add your clip host if clips points off-origin.
Theme
SekyroThemeProvider writes CSS variables on a .sekyro-root wrapper:
--sekyro-color-{brand,brand-fg,fg,fg-muted,bg,surface,ring,danger,success,warning},
--sekyro-radius, --sekyro-font. Without a provider the stylesheet's :root
defaults apply (DEFAULT_THEME). You can also set the variables yourself on any
ancestor.
Contrast rules the component relies on (checked for the default theme in
test/contrast.test.ts):
fgonsurface/bg,brandFgonbrand: text → 7:1 (AAA);surfaceonfg: the stage scrim isfgand the guidance line issurface, so the on-camera text inherits the same ratio;fgMutedonsurface: secondary text only (security note, checking hint) — AA (4.5:1) is the floor, AAA recommended;brand,ring,danger,warning,successonsurface: non-text UI → 3:1.
Strings
Keys follow the hosted message catalog (capture.*, fcv2.err.*, fcv3.*,
reasons.*, errors.attestation_invalid, errors.network_error, common.retry).
hintStringKey(hint) maps a GateHint code to its guidance key;
reasonStringKey(reason) maps a result reason to its copy key.
Accessibility
- One always-mounted
aria-live="polite"guidance line is the sole announcement channel during capture; failures userole="alert". - Every interactive element is ≥ 48 px with a 3 px
--sekyro-color-ringfocus ring. prefers-reduced-motiondisables all stage animations; thesekyro-capture-reduceclass forces the same for low-end devices.- Inline SVG icons only; no emoji; logical CSS properties only (
scripts/check-css.mjsfails the build on any physicalleft/right).
Entries: @sekyro/react and @sekyro/react/verification
The main entry is the 0.1 liveness component above. The profile v2 steps
(<SekyroVerification>, <PhoneOtpStep>, <MethodChoice>, <NniEntry>,
<DocumentCapture>, <VerificationResultScreen>) live on a second entry,
@sekyro/react/verification, with its own stylesheet
(import "@sekyro/react/verification.css" next to styles.css). It needs
@sekyro/web 0.1.0-rc.8 or later (the plan runner).
Use the ESM build (import). In ESM both entries import one shared copy of
the common code (theme, strings, <LivenessCapture>), and the document camera
view is a separate chunk loaded only when a document step shows. The CJS build
(require) has no code splitting: each entry is a self-contained bundle with
its own copy of the shared modules, so nothing module-level is shared
between @sekyro/react and @sekyro/react/verification (for example, a
SekyroThemeProvider or <LivenessCapture> imported from one entry is a
different component from the one the other entry renders), the camera view is
inlined, and the code is downloaded twice if you import both. The theme itself
is plain CSS variables on .sekyro-root, so a provider from either entry still
themes both, but every bundler partners use (Next, Vite, webpack 5, esbuild)
resolves the ESM build by default, so keep it that way.
Scripts
npm test (CSS lint + vitest: behaviour, jest-axe per state × ar/fr, contrast, RTL
snapshot) · npm run typecheck · npm run build (tsup ESM + CJS + d.ts +
dist/styles.css) · npm run api:check (api-extractor against
etc/sekyro-react.api.md).