Custom UI
Build a fully custom scanning interface on top of the headless Circadify SDK.
The SDK is headless. Use the per-frame callbacks and your own DOM/canvas/3D rendering to build a scan UI that matches your application's design language exactly.
For a face overlay or the thermal/heat-glow scan visual, the fastest and safest path is the bundled CircadifyScanView from the Web SDK's React bindings — it ships a ready-made glow overlay, so you don't write any rendering code.
If you must draw your own overlay from onLandmarks, prefer the 2D <canvas> approach shown below (drawFaceOverlay) — it's immediate-mode, so there's no retained geometry to leak or freeze. Do not hand-roll a three.js / @react-three/fiber overlay unless you genuinely need 3D; see Advanced: a 3D (r3f) overlay for the gotchas before you do.
Landmarks are normalized to [0,1] and are not mirrored — multiply x/y by your render width/height; don't pre-flip.
Architecture
You handle all visual rendering; the SDK manages capture, processing, and result delivery. The split:
+---------------------+ Callbacks +---------------------+
| Your Code | <------------------- | Circadify SDK |
| | | |
| - <video> element | onProgress | - Camera access |
| - Progress ring | onQualityState | - Face detection |
| - Quality pills | onQualityWarning | - Measurement prep |
| - Face overlay | onLandmarks | - SDK runtime |
| - Result display | onCameraReady | - Secure upload |
+---------------------+ +---------------------+
| |
| User clicks "Start" |
+------- measureVitals() ------------------>|
| AbortController.abort() |
+------- signal --------------------------->|text- Your code — renders the UI, handles user interaction, displays all visual feedback.
- Circadify SDK — manages camera access, local measurement preparation, secure upload, and result delivery.
- Callbacks — set on the constructor, the SDK reports progress, quality, landmarks, and camera-ready signals through them.
Setting Up
import { CircadifySDK } from '@circadify/web-sdk';
const sdk = new CircadifySDK({
apiKey: 'ck_live_your_key_here',
onProgress: ({ percent, phase }) => renderProgressRing(percent, phase),
onQualityState: (q) => renderQualityPills(q),
onQualityWarning: (w) => renderToast(w.message, w.severity),
onLandmarks: (lm) => renderFaceOverlay(lm), // 2D canvas recommended — see "Face overlay" below; or use CircadifyScanView
});javascriptRendering the Camera Feed
You own the <video> element. Pass it to measureVitals() as videoElement — the SDK binds the camera stream to it:
<video id="camera-feed" autoplay playsinline muted></video>htmlconst videoEl = document.getElementById('camera-feed');
const result = await sdk.measureVitals({
videoElement: videoEl,
demographics: { age: 35, sex: 'M' },
});javascriptYou control the video element entirely: size, position, CSS transforms (e.g. transform: scaleX(-1) for mirroring), border styling, overlays, and z-index layering are all yours to define.
If you skip videoElement, the SDK creates an off-DOM video and emits onCameraReady({ stream, video }) — you can mirror the live stream into your own UI by setting myPreviewVideo.srcObject = stream.
Building the UI
Here is a complete example tying together a custom camera feed, progress indicator, quality feedback, and results display:
<div class="scan-wrapper">
<video id="camera-feed" autoplay playsinline muted></video>
<canvas id="face-overlay"></canvas>
<div id="progress-ring"></div>
<p id="feedback-message" aria-live="polite"></p>
<div id="results-card" hidden></div>
<p id="error-message" hidden></p>
<button id="start-btn">Start Scan</button>
<button id="cancel-btn" hidden>Cancel</button>
</div>htmlimport { CircadifySDK } from '@circadify/web-sdk';
const videoEl = document.getElementById('camera-feed');
const overlayEl = document.getElementById('face-overlay');
const sdk = new CircadifySDK({
apiKey: 'ck_live_your_key_here',
onProgress: ({ percent, phase }) => {
renderProgressRing(percent);
renderFeedbackMessage(
phase === 'capturing' ? 'Measuring...' :
phase === 'processing' ? 'Analyzing...' :
'Hold still...'
);
},
onQualityState: (q) => {
if (!q.isReady && q.messages.length > 0) {
renderFeedbackMessage(q.messages[0]);
}
},
onLandmarks: (landmarks) => {
drawFaceOverlay(overlayEl, landmarks);
},
});
let controller = null;
document.getElementById('start-btn').addEventListener('click', async () => {
// A fresh AbortController per scan — an aborted controller stays aborted,
// so reusing one would make every scan after a Cancel reject immediately.
controller = new AbortController();
document.getElementById('start-btn').hidden = true;
document.getElementById('cancel-btn').hidden = false;
document.getElementById('error-message').hidden = true;
try {
const result = await sdk.measureVitals({
videoElement: videoEl,
signal: controller.signal,
demographics: { age: 35, sex: 'M' },
});
renderResultsCard(result);
} catch (error) {
if (error.code !== 'CANCELLED') {
renderErrorState(error.message);
}
} finally {
document.getElementById('start-btn').hidden = false;
document.getElementById('cancel-btn').hidden = true;
controller = null;
}
});
document.getElementById('cancel-btn').addEventListener('click', () => {
controller?.abort();
});
function renderProgressRing(percent) {
document.getElementById('progress-ring').style.background =
`conic-gradient(#3b82f6 ${percent * 3.6}deg, #e5e7eb 0deg)`;
}
function renderFeedbackMessage(message) {
document.getElementById('feedback-message').textContent = message;
}
function renderResultsCard(result) {
const card = document.getElementById('results-card');
card.hidden = false;
card.innerHTML = `
<p>Heart Rate: ${result.heartRate} BPM</p>
<p>SpO2: ${result.spo2 ?? '---'}%</p>
<p>Respiratory Rate: ${result.respiratoryRate ?? '---'} breaths/min</p>
<p>Confidence: ${(result.confidence * 100).toFixed(0)}%</p>
`;
}
function renderErrorState(message) {
const el = document.getElementById('error-message');
el.textContent = message;
el.hidden = false;
}
function drawFaceOverlay(canvas, landmarks) {
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = 'rgba(59, 130, 246, 0.6)';
for (const lm of landmarks) {
ctx.beginPath();
ctx.arc(lm.x * canvas.width, lm.y * canvas.height, 1, 0, Math.PI * 2);
ctx.fill();
}
}javascriptFace overlay: recommended (2D canvas)
The drawFaceOverlay helper above is the recommended way to render a face overlay. It runs in immediate mode: every onLandmarks frame it calls clearRect and redraws. Because nothing is retained between frames, there is no geometry to leak, orphan, or freeze — the entire class of bugs that bites a retained-mode 3D overlay simply cannot occur here.
Key points:
- Clear on every frame (
clearRect) before redrawing, and clear on empty-landmark frames too — whenonLandmarksfires with no landmarks (the face is lost), the canvas blanks instead of leaving a stale overlay frozen on screen. - Coordinates are normalized
[0,1]and not mirrored — multiplylm.x/lm.ybycanvas.width/canvas.height. If your<video>is CSS-mirrored (transform: scaleX(-1)), mirror the overlay element the same way rather than flipping coordinates in math. - Size the
<canvas>to match the video's rendered box and absolutely-position it over the feed (position: absolute; inset: 0; pointer-events: none;).
If all you want is the polished thermal/heat-glow look, you don't need any of this — drop in CircadifyScanView from the Web SDK's React bindings and the overlay is rendered for you.
Advanced: a 3D (r3f) overlay
A three.js / @react-three/fiber overlay is advanced and easy to get wrong. The most common failure: creating BufferGeometry / BufferAttributes in useMemo. React treats useMemo as a cache it may discard under load — when it does, the recomputed value orphans the GPU buffers and the overlay renders one frame and then freezes. Before going down this path, prefer the bundled CircadifyScanView or the 2D canvas above.
If you must build a 3D overlay, obey all of these:
- Never create geometry/attributes in
useMemo. Build them once viauseStatelazy-init —useState(() => new THREE.BufferGeometry())— and mutateattribute.array+ setattribute.needsUpdate = trueeach frame. - Set
frameloop="always"on the<Canvas>(don't rely on on-demand invalidation for a live overlay). - Set
frustumCulled=on the overlay mesh/points so normalized-coordinate geometry isn't culled. - Use an orthographic camera with
zoom=so[0,1]landmark coords map predictably to the frame. - Render into a z-indexed overlay layer above the
<video>(position: absolute; inset: 0; pointer-events: none;with a higherz-indexthan the feed). - Clear the overlay on empty-
onLandmarksframes (face lost) — zero/hide the geometry instead of leaving the last frame frozen. - Landmarks are normalized
[0,1]and NOT mirrored — scale by render width/height; mirror the overlay layer (CSSscaleX(-1)) to match a mirrored preview rather than flipping coordinates.
On @react-three/fiber v9 the freeze surfaces faster and the reconciler is stricter about objects created during render, so the useState lazy-init rule is mandatory; pin to a fiber 8.x line if you don't need 9.
Styling Guidelines
When building custom capture UIs, follow these guidelines to provide a reliable user experience:
Visual Feedback
- Show a continuous progress indicator (ring, bar, or percentage) during the scan so users know the measurement is active.
- Drive live quality pills from
onQualityState(lighting, motion, pose, readiness). - Use
onQualityWarningfor transient toast notifications when conditions briefly degrade. - Provide a clear distinction between the "scanning" and "complete" states.
Accessibility
- Ensure all interactive elements (start, cancel buttons) are keyboard-accessible and have visible focus indicators.
- Use
aria-live="polite"regions for progress and feedback text so screen readers announce updates without interrupting the user. - Pair color-based status indicators with text labels (do not rely on color alone).
- Manage focus: move focus to the results area on completion, or to the error message on failure.
Responsive Layout
- Use relative units or
aspect-ratiofor the video container so it scales across screen sizes. - On mobile devices, consider a full-viewport scan view with an overlay for feedback and controls.
- Test on both portrait and landscape orientations.
Error Recovery
- Map SDK error codes to user-friendly messages that explain what went wrong and what to do next.
- For retryable errors (timeouts, processing failures), offer a "Try Again" button that resets the UI and starts a new session.
- For non-retryable errors (permission denied), guide the user to their browser or OS settings.
Next Steps
- React — Use the Web SDK's pre-built React components (incl.
CircadifyScanView's ready-made heat-glow overlay) instead of building from scratch - Configuration — Full callback reference
- Events — Detailed event semantics
- Python SDK — Face-Glow Overlay — Draw Circadify's thermal face-glow on your own frames in Python with
render_face_glow - Troubleshooting — Common issues and fixes