Skip to content

Custom UI

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.

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 | - ROI extraction |
| - Face overlay | onLandmarks | - WASM runtime |
| - Result display | onCameraReady | - Tensor upload |
+---------------------+ +---------------------+
| |
| User clicks "Start" |
+------- measureVitals() ------------------>|
| AbortController.abort() |
+------- signal --------------------------->|
  1. Your code — renders the UI, handles user interaction, displays all visual feedback.
  2. Circadify SDK — manages camera access, face detection, on-device processing, server upload, and result delivery.
  3. Callbacks — set on the constructor, the SDK reports progress, quality, landmarks, and camera-ready signals through them.
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),
});

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>
const videoEl = document.getElementById('camera-feed');
const result = await sdk.measureVitals({
videoElement: videoEl,
demographics: { age: 35, sex: 'M' },
});

You 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.

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>
import { 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) => {
drawFaceMesh(overlayEl, landmarks);
},
});
const controller = new AbortController();
document.getElementById('start-btn').addEventListener('click', async () => {
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;
}
});
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 drawFaceMesh(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();
}
}

When building custom capture UIs, follow these guidelines to provide a reliable user experience:

  • 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 onQualityWarning for transient toast notifications when conditions briefly degrade.
  • Provide a clear distinction between the “scanning” and “complete” states.
  • 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.
  • Use relative units or aspect-ratio for 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.
  • 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.