Banking — ID Verification
A trust-first KYC identity-check flow for fintech onboarding, built in vanilla JS. Customers pick a document type, then upload front and back through drag-and-drop zones that show a live progress bar, a masked preview thumbnail, and a sharp-and-readable status before a mock selfie liveness step runs blink and head-turn checks. An animated scan line reads the document, compares biometrics, and screens for sanctions, ending in a verified result card with match score and reference, or a needs-review state. Fully keyboard-usable and responsive to 360px.
MCP
Code
:root {
--navy: #0e1b3a;
--navy-2: #16264d;
--ink: #0e1726;
--ink-2: #3a4660;
--muted: #697089;
--accent: #3b6ef6;
--accent-d: #2a55cc;
--accent-50: #eaf0ff;
--teal: #0fb5a6;
--violet: #7c5cff;
--bg: #f5f7fb;
--surface: #ffffff;
--line: rgba(14, 27, 58, 0.10);
--line-2: rgba(14, 27, 58, 0.18);
--ok: #1f9d62;
--warn: #d9982b;
--danger: #d4493e;
--credit: #1f9d62;
--debit: #0e1726;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--sh-1: 0 1px 2px rgba(14, 27, 58, 0.06), 0 1px 3px rgba(14, 27, 58, 0.05);
--sh-2: 0 6px 18px rgba(14, 27, 58, 0.10), 0 2px 6px rgba(14, 27, 58, 0.06);
--sh-3: 0 24px 60px rgba(14, 27, 58, 0.16), 0 8px 20px rgba(14, 27, 58, 0.08);
}
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
margin: 0;
min-height: 100vh;
padding: 32px 18px;
display: flex;
align-items: flex-start;
justify-content: center;
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
color: var(--ink);
background:
radial-gradient(1100px 520px at 50% -8%, #e9eefc 0%, transparent 60%),
var(--bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-variant-numeric: tabular-nums;
}
.shell { width: 100%; max-width: 480px; }
.mono { font-variant-numeric: tabular-nums; letter-spacing: .02em; }
/* Card shell */
.card {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--sh-3);
overflow: hidden;
}
.card__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
color: #fff;
background: linear-gradient(135deg, var(--navy) 0%, var(--navy-2) 100%);
}
.brand { display: flex; align-items: center; gap: 11px; }
.brand__mark {
display: grid; place-items: center;
width: 36px; height: 36px;
border-radius: 10px;
color: #cdfcf5;
background: rgba(15, 181, 166, 0.18);
border: 1px solid rgba(15, 181, 166, 0.35);
}
.brand__txt { display: flex; flex-direction: column; line-height: 1.25; }
.brand__txt strong { font-size: 15px; font-weight: 700; letter-spacing: -.01em; }
.brand__txt span { font-size: 11.5px; color: #b9c4e0; }
.secure {
display: inline-flex; align-items: center; gap: 6px;
font-size: 11.5px; font-weight: 600;
color: #cdfcf5;
padding: 5px 9px;
border-radius: 999px;
background: rgba(15, 181, 166, 0.14);
border: 1px solid rgba(15, 181, 166, 0.3);
white-space: nowrap;
}
/* Stepper */
.steps {
display: flex;
list-style: none;
margin: 0;
padding: 14px 20px;
gap: 6px;
border-bottom: 1px solid var(--line);
background: #fafbff;
}
.step {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 7px;
position: relative;
font-size: 11px;
font-weight: 600;
color: var(--muted);
}
.step::before {
content: "";
position: absolute;
top: 12px; left: -50%;
width: 100%; height: 2px;
background: var(--line-2);
z-index: 0;
}
.step:first-child::before { display: none; }
.step__dot {
position: relative; z-index: 1;
width: 24px; height: 24px;
display: grid; place-items: center;
border-radius: 50%;
font-size: 12px; font-weight: 700;
color: var(--muted);
background: #fff;
border: 2px solid var(--line-2);
transition: all .25s ease;
}
.step__lbl { transition: color .25s ease; }
.step.is-active .step__dot { color: #fff; background: var(--accent); border-color: var(--accent); box-shadow: 0 0 0 4px var(--accent-50); }
.step.is-active .step__lbl { color: var(--ink); }
.step.is-done .step__dot { color: #fff; background: var(--ok); border-color: var(--ok); }
.step.is-done::before { background: var(--ok); }
.step.is-done .step__lbl { color: var(--ink-2); }
/* Body */
.card__body { padding: 22px 20px 24px; }
.title { margin: 0 0 4px; font-size: 21px; font-weight: 800; letter-spacing: -.02em; }
.sub { margin: 0 0 18px; font-size: 13.5px; color: var(--muted); }
.sub strong { color: var(--ink-2); font-weight: 600; }
.eyebrow {
margin: 0 0 11px;
font-size: 11.5px; font-weight: 700;
text-transform: uppercase; letter-spacing: .06em;
color: var(--muted);
}
/* Panels */
.panel { display: none; animation: fade .35s ease; }
.panel.is-active { display: block; }
@keyframes fade { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
/* Doc grid */
.doc-grid { display: grid; gap: 10px; margin-bottom: 16px; }
.doc {
display: grid;
grid-template-columns: 44px 1fr;
grid-template-rows: auto auto;
align-items: center;
gap: 0 13px;
text-align: left;
padding: 13px 15px;
border-radius: var(--r-md);
border: 1.5px solid var(--line);
background: var(--surface);
cursor: pointer;
font: inherit; color: inherit;
transition: border-color .18s, box-shadow .18s, background .18s, transform .12s;
}
.doc:hover { border-color: var(--line-2); box-shadow: var(--sh-1); }
.doc:active { transform: scale(.99); }
.doc:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 4px var(--accent-50); }
.doc__ico {
grid-row: 1 / span 2;
display: grid; place-items: center;
width: 44px; height: 44px;
border-radius: 11px;
color: var(--accent);
background: var(--accent-50);
transition: all .18s;
}
.doc__name { font-size: 14.5px; font-weight: 700; letter-spacing: -.01em; }
.doc__meta { font-size: 12px; color: var(--muted); }
.doc[aria-checked="true"] {
border-color: var(--accent);
background: linear-gradient(0deg, var(--accent-50), #fff);
box-shadow: 0 0 0 4px var(--accent-50);
}
.doc[aria-checked="true"] .doc__ico { color: #fff; background: var(--accent); }
/* Tips */
.tips {
display: flex; gap: 11px;
padding: 12px 14px;
border-radius: var(--r-md);
background: #f1f7f6;
border: 1px solid rgba(15, 181, 166, 0.22);
margin-bottom: 16px;
}
.tips__ico {
flex: 0 0 auto;
width: 20px; height: 20px;
display: grid; place-items: center;
border-radius: 50%;
font-size: 12px; font-weight: 800; font-style: italic;
color: #fff; background: var(--teal);
}
.tips ul { margin: 0; padding-left: 18px; font-size: 12.5px; color: var(--ink-2); }
.tips li + li { margin-top: 3px; }
/* Buttons */
.btn {
appearance: none;
border: 1px solid transparent;
border-radius: 11px;
padding: 12px 18px;
font: inherit; font-size: 14px; font-weight: 700;
cursor: pointer;
transition: background .16s, border-color .16s, box-shadow .16s, transform .1s, color .16s, opacity .16s;
letter-spacing: -.01em;
}
.btn:active { transform: translateY(1px); }
.btn:focus-visible { outline: none; box-shadow: 0 0 0 4px var(--accent-50); }
.btn--primary { color: #fff; background: var(--accent); box-shadow: var(--sh-1); }
.btn--primary:hover { background: var(--accent-d); }
.btn--primary:disabled { background: #b9c4e0; cursor: not-allowed; box-shadow: none; opacity: .8; }
.btn--ghost { color: var(--ink-2); background: #fff; border-color: var(--line-2); }
.btn--ghost:hover { background: #f4f6fc; }
.btn--block { width: 100%; }
.nav { display: flex; gap: 10px; margin-top: 18px; }
.nav .btn { flex: 1; }
.nav .btn--ghost { flex: 0 0 auto; min-width: 92px; }
/* Drop zones */
.drops { display: grid; gap: 12px; }
.drop {
position: relative;
border-radius: var(--r-md);
border: 2px dashed var(--line-2);
background:
repeating-linear-gradient(45deg, #fbfcff 0 12px, #f6f8fe 12px 24px);
padding: 18px 16px;
text-align: center;
cursor: pointer;
transition: border-color .18s, background .18s, box-shadow .18s;
min-height: 132px;
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px;
}
.drop:hover { border-color: var(--accent); }
.drop.is-over { border-color: var(--accent); background: var(--accent-50); box-shadow: 0 0 0 4px var(--accent-50); }
.drop:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 4px var(--accent-50); }
.drop__ico { color: var(--accent); }
.drop__title { font-size: 13.5px; font-weight: 700; }
.drop__hint { font-size: 12px; color: var(--muted); }
.drop__hint b { color: var(--accent-d); font-weight: 700; }
/* Filled state — fake preview */
.drop.is-filled {
border-style: solid;
border-color: var(--ok);
background: var(--surface);
padding: 0;
overflow: hidden;
}
.preview {
width: 100%;
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: 12px;
padding: 12px 14px;
text-align: left;
}
.preview__thumb {
grid-row: 1 / span 2;
width: 78px; height: 50px;
border-radius: 8px;
position: relative;
overflow: hidden;
background: linear-gradient(135deg, var(--navy) 0%, var(--navy-2) 60%, var(--violet) 130%);
box-shadow: var(--sh-1);
}
.preview__thumb::before {
content: "";
position: absolute; left: 7px; top: 8px;
width: 18px; height: 22px; border-radius: 4px;
background: rgba(205, 252, 245, 0.85);
}
.preview__thumb::after {
content: "";
position: absolute; right: 8px; top: 12px;
width: 38px; height: 5px; border-radius: 3px;
background: rgba(255, 255, 255, 0.55);
box-shadow: 0 9px 0 rgba(255, 255, 255, 0.4), 0 18px 0 rgba(255, 255, 255, 0.28);
}
.preview__name { font-size: 13px; font-weight: 700; }
.preview__meta { font-size: 11.5px; color: var(--muted); display: flex; align-items: center; gap: 6px; }
.preview__del {
grid-row: 1 / span 2;
appearance: none; border: 1px solid var(--line-2); background: #fff;
width: 30px; height: 30px; border-radius: 8px;
display: grid; place-items: center; cursor: pointer; color: var(--muted);
transition: color .15s, border-color .15s, background .15s;
}
.preview__del:hover { color: var(--danger); border-color: var(--danger); background: #fdf0ef; }
.preview__del:focus-visible { outline: none; box-shadow: 0 0 0 4px var(--accent-50); }
.preview__bar { grid-column: 1 / -1; height: 4px; border-radius: 99px; background: var(--line); overflow: hidden; }
.preview__bar i { display: block; height: 100%; width: 0; border-radius: 99px; background: linear-gradient(90deg, var(--accent), var(--teal)); transition: width .15s linear; }
.drop.is-uploading .preview__del { visibility: hidden; }
.pill {
display: inline-flex; align-items: center; gap: 5px;
font-size: 11px; font-weight: 700;
padding: 3px 9px; border-radius: 999px;
line-height: 1;
}
.pill--ok { color: var(--ok); background: rgba(31, 157, 98, 0.12); }
.pill--ok::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--ok); }
.pill--pending { color: var(--warn); background: rgba(217, 152, 43, 0.14); }
.pill--pending::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--warn); }
/* Selfie */
.selfie { text-align: center; }
.selfie__stage {
position: relative;
width: 188px; height: 188px;
margin: 4px auto 18px;
display: grid; place-items: center;
}
.selfie__ring {
position: absolute; inset: 0;
border-radius: 50%;
border: 3px dashed var(--line-2);
transition: border-color .3s;
}
.selfie__stage.is-active .selfie__ring {
border-style: solid;
border-color: var(--accent);
animation: spin 2.4s linear infinite;
}
.selfie__stage.is-ok .selfie__ring { border-color: var(--ok); border-style: solid; animation: none; }
@keyframes spin { to { transform: rotate(360deg); } }
.selfie__face {
width: 150px; height: 150px;
border-radius: 50%;
display: grid; place-items: center;
color: var(--muted);
background: radial-gradient(circle at 50% 35%, #eef2fb, #e2e8f6);
overflow: hidden;
transition: color .3s, background .3s;
}
.selfie__stage.is-active .selfie__face { color: var(--accent); }
.selfie__stage.is-ok .selfie__face { color: var(--ok); background: radial-gradient(circle at 50% 35%, #e7f6ee, #d6efe0); }
.selfie__hint {
position: absolute; bottom: -2px; left: 50%; transform: translate(-50%, 100%);
font-size: 12px; font-weight: 600; color: var(--muted);
white-space: nowrap;
}
.liveness { list-style: none; margin: 26px 0 0; padding: 0; display: grid; gap: 9px; max-width: 240px; margin-inline: auto; }
.liveness li {
display: flex; align-items: center; gap: 10px;
font-size: 13px; font-weight: 600; color: var(--muted);
text-align: left;
transition: color .25s;
}
.liveness .tick {
flex: 0 0 auto;
width: 20px; height: 20px; border-radius: 50%;
border: 2px solid var(--line-2);
display: grid; place-items: center;
position: relative;
transition: all .25s;
}
.liveness li.is-done { color: var(--ink); }
.liveness li.is-done .tick { background: var(--ok); border-color: var(--ok); }
.liveness li.is-done .tick::after {
content: ""; width: 6px; height: 9px;
border: solid #fff; border-width: 0 2px 2px 0;
transform: rotate(45deg) translate(-1px, -1px);
}
/* Scan */
.scan { text-align: center; padding: 6px 0 2px; }
.scan__doc {
position: relative;
width: 220px; height: 138px;
margin: 6px auto 18px;
border-radius: var(--r-md);
background: linear-gradient(135deg, var(--navy) 0%, var(--navy-2) 70%, var(--violet) 150%);
box-shadow: var(--sh-2);
overflow: hidden;
}
.scan__photo {
position: absolute; left: 16px; top: 20px;
width: 46px; height: 58px; border-radius: 6px;
background: rgba(205, 252, 245, 0.55);
}
.scan__lines { position: absolute; right: 16px; top: 26px; display: grid; gap: 9px; }
.scan__lines i { display: block; height: 7px; border-radius: 4px; background: rgba(255, 255, 255, 0.4); }
.scan__lines i:nth-child(1) { width: 120px; }
.scan__lines i:nth-child(2) { width: 90px; }
.scan__lines i:nth-child(3) { width: 104px; }
.scan__line {
position: absolute; left: 0; right: 0; top: 0; height: 28px;
background: linear-gradient(180deg, rgba(15, 181, 166, 0) 0%, rgba(15, 181, 166, 0.55) 60%, rgba(205, 252, 245, 0.95) 100%);
box-shadow: 0 0 18px rgba(15, 181, 166, 0.7);
animation: scanline 1.6s ease-in-out infinite;
}
@keyframes scanline { 0% { top: -28px; } 100% { top: 138px; } }
.scan.is-done .scan__line { display: none; }
.scan__status { margin: 0 0 12px; font-size: 14px; font-weight: 700; }
.bar { height: 7px; border-radius: 99px; background: var(--line); overflow: hidden; max-width: 260px; margin: 0 auto 16px; }
.bar__fill { display: block; height: 100%; width: 0; border-radius: 99px; background: linear-gradient(90deg, var(--accent), var(--teal)); transition: width .35s ease; }
.scan__list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; max-width: 260px; margin-inline: auto; }
.scan__list li {
display: flex; align-items: center; gap: 10px;
font-size: 12.5px; font-weight: 600; color: var(--muted);
text-align: left;
}
.scan__list li::before {
content: ""; flex: 0 0 auto;
width: 16px; height: 16px; border-radius: 50%;
border: 2px solid var(--line-2);
transition: all .25s;
}
.scan__list li.is-busy::before { border-color: var(--accent); border-top-color: transparent; animation: spin .7s linear infinite; }
.scan__list li.is-done { color: var(--ink); }
.scan__list li.is-done::before { background: var(--ok); border-color: var(--ok); }
/* Result */
.result { text-align: center; animation: fade .4s ease; }
.result__badge {
width: 68px; height: 68px; margin: 4px auto 14px;
border-radius: 50%;
display: grid; place-items: center;
color: #fff;
background: var(--ok);
box-shadow: 0 0 0 8px rgba(31, 157, 98, 0.12);
animation: pop .4s cubic-bezier(.2, 1.3, .5, 1) both;
}
.result__badge.is-fail { background: var(--danger); box-shadow: 0 0 0 8px rgba(212, 73, 62, 0.12); }
@keyframes pop { from { transform: scale(.4); opacity: 0; } to { transform: scale(1); opacity: 1; } }
.result__title { margin: 0 0 6px; font-size: 20px; font-weight: 800; letter-spacing: -.02em; }
.result__sub { margin: 0 auto 18px; font-size: 13.5px; color: var(--muted); max-width: 330px; }
.result__facts { margin: 0 0 4px; padding: 0; border: 1px solid var(--line); border-radius: var(--r-md); overflow: hidden; text-align: left; }
.result__facts > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 11px 15px; }
.result__facts > div + div { border-top: 1px solid var(--line); }
.result__facts dt { margin: 0; font-size: 12.5px; color: var(--muted); font-weight: 600; }
.result__facts dd { margin: 0; font-size: 13.5px; font-weight: 700; }
/* Legal */
.legal { margin: 14px 4px 0; font-size: 11.5px; color: var(--muted); text-align: center; line-height: 1.5; }
/* Toast */
.toast-wrap {
position: fixed; left: 0; right: 0; bottom: 18px;
display: flex; flex-direction: column; align-items: center; gap: 8px;
pointer-events: none; z-index: 50; padding: 0 12px;
}
.toast {
pointer-events: auto;
display: inline-flex; align-items: center; gap: 9px;
max-width: 92vw;
padding: 11px 16px;
border-radius: 12px;
font-size: 13px; font-weight: 600;
color: #fff;
background: var(--ink);
box-shadow: var(--sh-3);
transform: translateY(16px); opacity: 0;
transition: transform .28s cubic-bezier(.2,.9,.3,1), opacity .28s;
}
.toast.is-in { transform: none; opacity: 1; }
.toast::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--teal); }
.toast.toast--ok::before { background: var(--ok); }
.toast.toast--warn::before { background: var(--warn); }
.toast.toast--err::before { background: var(--danger); }
@media (max-width: 520px) {
body { padding: 16px 12px 90px; }
.card__head { padding: 14px 16px; }
.secure { font-size: 10.5px; padding: 4px 8px; }
.steps { padding: 12px 12px; }
.step__lbl { font-size: 10px; }
.card__body { padding: 18px 16px 20px; }
.title { font-size: 19px; }
.selfie__stage { width: 168px; height: 168px; }
.selfie__face { width: 134px; height: 134px; }
.nav { flex-wrap: wrap; }
.nav .btn--ghost { flex: 1; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .05ms !important; }
}(function () {
"use strict";
var $ = function (s, r) { return (r || document).querySelector(s); };
var $$ = function (s, r) { return Array.prototype.slice.call((r || document).querySelectorAll(s)); };
/* ---------- toast helper ---------- */
var toastWrap = $("[data-toast-wrap]");
function toast(msg, kind) {
var el = document.createElement("div");
el.className = "toast" + (kind ? " toast--" + kind : "");
el.textContent = msg;
toastWrap.appendChild(el);
requestAnimationFrame(function () { el.classList.add("is-in"); });
setTimeout(function () {
el.classList.remove("is-in");
setTimeout(function () { el.remove(); }, 320);
}, 2600);
}
/* ---------- doc config ---------- */
var DOCS = {
passport: { label: "passport", sides: [{ key: "main", title: "Photo page", hint: "the page with your photo" }] },
license: { label: "driver's licence", sides: [
{ key: "front", title: "Front side", hint: "the side with your photo" },
{ key: "back", title: "Back side", hint: "the side with the barcode" }
] },
idcard: { label: "national ID", sides: [
{ key: "front", title: "Front side", hint: "the side with your photo" },
{ key: "back", title: "Back side", hint: "the reverse with the MRZ" }
] }
};
var state = { doc: "passport", uploads: {}, scanRunning: false };
/* ---------- stepper ---------- */
var stepEls = $$(".step");
var panels = $$(".panel");
var current = 0;
function goto(idx) {
current = idx;
panels.forEach(function (p) {
var on = Number(p.getAttribute("data-panel")) === idx;
p.classList.toggle("is-active", on);
p.hidden = !on;
});
stepEls.forEach(function (s, i) {
s.classList.toggle("is-active", i === idx);
s.classList.toggle("is-done", i < idx);
});
var card = $(".card");
if (card && card.scrollIntoView) card.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
/* ---------- panel 0: doc selection ---------- */
$$(".doc").forEach(function (btn) {
btn.addEventListener("click", function () {
$$(".doc").forEach(function (b) { b.setAttribute("aria-checked", "false"); });
btn.setAttribute("aria-checked", "true");
state.doc = btn.getAttribute("data-doc");
});
});
/* ---------- panel 1: build drop zones ---------- */
var dropsHost = $("[data-drops]");
var uploadNextBtn = $("[data-upload-next]");
var docLabel = $("[data-doc-label]");
function svgUpload() {
return '<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 16V4"/><path d="m7 9 5-5 5 5"/><path d="M5 16v3a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-3"/></svg>';
}
function svgTrash() {
return '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 6h18M8 6V4h8v2M6 6l1 14h10l1-14"/></svg>';
}
function buildUploads() {
var cfg = DOCS[state.doc];
docLabel.textContent = cfg.label;
dropsHost.innerHTML = "";
state.uploads = {};
cfg.sides.forEach(function (side) {
var drop = document.createElement("button");
drop.type = "button";
drop.className = "drop";
drop.setAttribute("data-side", side.key);
drop.setAttribute("aria-label", "Upload " + side.title);
drop.innerHTML =
'<span class="drop__ico" aria-hidden="true">' + svgUpload() + "</span>" +
'<span class="drop__title">' + side.title + "</span>" +
'<span class="drop__hint"><b>Click to upload</b> or drag a file here</span>';
dropsHost.appendChild(drop);
wireDrop(drop, side);
});
refreshUploadNext();
}
function refreshUploadNext() {
var cfg = DOCS[state.doc];
var done = cfg.sides.every(function (s) { return state.uploads[s.key] === "done"; });
uploadNextBtn.disabled = !done;
}
function wireDrop(drop, side) {
function start() {
if (drop.classList.contains("is-uploading") || drop.classList.contains("is-filled")) return;
simulateUpload(drop, side);
}
drop.addEventListener("click", function (e) {
if (e.target.closest("[data-del]")) return;
start();
});
drop.addEventListener("dragover", function (e) { e.preventDefault(); drop.classList.add("is-over"); });
drop.addEventListener("dragleave", function () { drop.classList.remove("is-over"); });
drop.addEventListener("drop", function (e) {
e.preventDefault();
drop.classList.remove("is-over");
start();
});
}
var SAMPLE_NAMES = { main: "passport-photo.jpg", front: "id-front.jpg", back: "id-back.jpg" };
function simulateUpload(drop, side) {
state.uploads[side.key] = "uploading";
drop.classList.add("is-filled", "is-uploading");
var fname = SAMPLE_NAMES[side.key] || "document.jpg";
var kb = (820 + Math.floor(Math.random() * 600));
drop.innerHTML =
'<div class="preview">' +
'<span class="preview__thumb" aria-hidden="true"></span>' +
'<span class="preview__name">' + fname + "</span>" +
'<span class="preview__meta"><span data-pct>Uploading… 0%</span> · ' + kb + " KB</span>" +
'<button class="preview__del" type="button" data-del aria-label="Remove file">' + svgTrash() + "</button>" +
'<span class="preview__bar"><i data-fill></i></span>' +
"</div>";
var fill = $("[data-fill]", drop);
var pct = $("[data-pct]", drop);
var del = $("[data-del]", drop);
del.addEventListener("click", function (e) {
e.stopPropagation();
resetDrop(drop, side);
});
var p = 0;
var iv = setInterval(function () {
p += 8 + Math.floor(Math.random() * 14);
if (p >= 100) {
p = 100;
clearInterval(iv);
fill.style.width = "100%";
pct.textContent = "Uploaded";
drop.classList.remove("is-uploading");
finishUpload(drop, side, fname, kb);
return;
}
fill.style.width = p + "%";
pct.textContent = "Uploading… " + p + "%";
}, 120);
}
function finishUpload(drop, side, fname, kb) {
state.uploads[side.key] = "done";
var meta = $(".preview__meta", drop);
meta.innerHTML = '<span class="pill pill--ok">Sharp & readable</span> · ' + kb + " KB";
refreshUploadNext();
toast(side.title + " uploaded", "ok");
}
function resetDrop(drop, side) {
delete state.uploads[side.key];
drop.classList.remove("is-filled", "is-uploading");
drop.innerHTML =
'<span class="drop__ico" aria-hidden="true">' + svgUpload() + "</span>" +
'<span class="drop__title">' + side.title + "</span>" +
'<span class="drop__hint"><b>Click to upload</b> or drag a file here</span>';
refreshUploadNext();
toast(side.title + " removed", "warn");
}
/* ---------- panel 2: liveness ---------- */
var selfieStage = $("[data-selfie]");
var selfieHint = $("[data-selfie-hint]");
var selfieStartBtn = $("[data-selfie-start]");
var livenessItems = $$("[data-liveness] li");
function resetLiveness() {
selfieStage.classList.remove("is-active", "is-ok");
selfieHint.textContent = "Center your face, then hold still";
livenessItems.forEach(function (li) { li.classList.remove("is-done"); });
selfieStartBtn.disabled = false;
selfieStartBtn.textContent = "Start liveness check";
}
selfieStartBtn.addEventListener("click", function () {
if (selfieStage.classList.contains("is-ok")) { goto(3); runScan(); return; }
selfieStartBtn.disabled = true;
selfieStage.classList.add("is-active");
selfieHint.textContent = "Scanning…";
var steps = [
{ i: 0, hint: "Hold still…" },
{ i: 1, hint: "Now blink" },
{ i: 2, hint: "Turn your head slightly" }
];
var n = 0;
function step() {
if (n >= steps.length) {
selfieStage.classList.remove("is-active");
selfieStage.classList.add("is-ok");
selfieHint.textContent = "Liveness confirmed";
selfieStartBtn.disabled = false;
selfieStartBtn.textContent = "Continue";
toast("Liveness confirmed", "ok");
return;
}
var s = steps[n];
selfieHint.textContent = s.hint;
setTimeout(function () {
livenessItems[s.i].classList.add("is-done");
n++;
step();
}, 850);
}
step();
});
/* ---------- panel 3: scan + result ---------- */
var scanEl = $("[data-scan]");
var scanStatus = $("[data-scan-status]");
var scanBar = $("[data-scan-bar]");
var scanTasks = $$("[data-scan-list] li");
var resultEl = $("[data-result]");
var resultBadge = $("[data-result-badge]");
var resultTitle = $("[data-result-title]");
var resultSub = $("[data-result-sub]");
var factScore = $("[data-fact-score]");
var factRef = $("[data-fact-ref]");
var factDoc = $("[data-fact-doc]");
var DOC_NAMES = { passport: "Passport", license: "Driver's licence", idcard: "National ID" };
function runScan() {
if (state.scanRunning) return;
state.scanRunning = true;
scanEl.hidden = false;
resultEl.hidden = true;
scanEl.classList.remove("is-done");
scanBar.style.width = "0%";
scanStatus.textContent = "Matching document to selfie…";
scanTasks.forEach(function (t) { t.classList.remove("is-busy", "is-done"); });
var phases = [
{ p: 34, status: "Reading document data…", task: 0 },
{ p: 68, status: "Comparing facial biometrics…", task: 1 },
{ p: 100, status: "Running sanctions & PEP screening…", task: 2 }
];
var idx = 0;
scanTasks[0].classList.add("is-busy");
function next() {
if (idx >= phases.length) { setTimeout(finishScan, 500); return; }
var ph = phases[idx];
scanStatus.textContent = ph.status;
scanBar.style.width = ph.p + "%";
setTimeout(function () {
scanTasks[ph.task].classList.remove("is-busy");
scanTasks[ph.task].classList.add("is-done");
if (idx + 1 < phases.length) scanTasks[phases[idx + 1].task].classList.add("is-busy");
idx++;
next();
}, 1050);
}
next();
}
function finishScan() {
scanEl.classList.add("is-done");
state.scanRunning = false;
// Demo: ~85% verified, otherwise needs review.
var pass = Math.random() > 0.15;
factDoc.textContent = DOC_NAMES[state.doc] || "Document";
factRef.textContent = "NB-" + Math.random().toString(36).slice(2, 8).toUpperCase();
if (pass) {
var score = 94 + Math.floor(Math.random() * 6);
resultBadge.classList.remove("is-fail");
resultBadge.innerHTML = '<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>';
resultTitle.textContent = "You're verified";
resultSub.textContent = "Welcome, Mara Velasquez. Your identity matches the documents provided.";
factScore.className = "pill pill--ok";
factScore.textContent = score + "% match";
toast("Identity verified", "ok");
} else {
resultBadge.classList.add("is-fail");
resultBadge.innerHTML = '<svg viewBox="0 0 24 24" width="30" height="30" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg>';
resultTitle.textContent = "We need another look";
resultSub.textContent = "The selfie didn't clearly match your document. Retake your photos in better light and try again.";
factScore.className = "pill pill--pending";
factScore.textContent = "62% match";
toast("Verification needs review", "err");
}
resultEl.hidden = false;
}
/* ---------- nav buttons ---------- */
$$("[data-next]").forEach(function (btn) {
btn.addEventListener("click", function () {
if (current === 0) { buildUploads(); goto(1); }
else if (current === 1) { resetLiveness(); goto(2); }
});
});
$$("[data-back]").forEach(function (btn) {
btn.addEventListener("click", function () { if (current > 0) goto(current - 1); });
});
$("[data-restart]").addEventListener("click", function () {
state.uploads = {};
resetLiveness();
goto(0);
toast("Starting over", "warn");
});
$("[data-done]").addEventListener("click", function () {
toast("Redirecting to your dashboard…", "ok");
});
goto(0);
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Banking — ID Verification</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="shell" role="main">
<section class="card" aria-labelledby="kyc-title">
<header class="card__head">
<div class="brand">
<span class="brand__mark" aria-hidden="true">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2 4 5v6c0 5 3.4 8.3 8 10 4.6-1.7 8-5 8-10V5z"/><path d="m9 12 2 2 4-4"/></svg>
</span>
<div class="brand__txt">
<strong>Northbank</strong>
<span>Identity check</span>
</div>
</div>
<span class="secure" aria-label="Connection is encrypted">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
256-bit secure
</span>
</header>
<!-- Stepper -->
<ol class="steps" aria-label="Verification progress">
<li class="step is-active" data-step="0"><span class="step__dot">1</span><span class="step__lbl">Document</span></li>
<li class="step" data-step="1"><span class="step__dot">2</span><span class="step__lbl">Upload</span></li>
<li class="step" data-step="2"><span class="step__dot">3</span><span class="step__lbl">Selfie</span></li>
<li class="step" data-step="3"><span class="step__dot">4</span><span class="step__lbl">Result</span></li>
</ol>
<div class="card__body">
<h1 id="kyc-title" class="title">Verify your identity</h1>
<p class="sub" id="kyc-sub">A quick check keeps account <strong>•••• 4242</strong> secure. It takes about 2 minutes.</p>
<!-- PANEL 0 — choose document -->
<section class="panel is-active" data-panel="0" aria-labelledby="kyc-title">
<p class="eyebrow">Choose a document type</p>
<div class="doc-grid" role="radiogroup" aria-label="Document type">
<button class="doc" role="radio" aria-checked="true" data-doc="passport" data-sides="1">
<span class="doc__ico" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="2" width="16" height="20" rx="2"/><circle cx="12" cy="10" r="3"/><path d="M8 17h8"/></svg>
</span>
<span class="doc__name">Passport</span>
<span class="doc__meta">1 page · fastest</span>
</button>
<button class="doc" role="radio" aria-checked="false" data-doc="license" data-sides="2">
<span class="doc__ico" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="5" width="20" height="14" rx="2"/><circle cx="8" cy="12" r="2.4"/><path d="M13 10h5M13 14h5"/></svg>
</span>
<span class="doc__name">Driver's licence</span>
<span class="doc__meta">front & back</span>
</button>
<button class="doc" role="radio" aria-checked="false" data-doc="idcard" data-sides="2">
<span class="doc__ico" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><circle cx="8" cy="11" r="2.2"/><path d="M5 16c.6-1.6 4.4-1.6 5 0M14 9h5M14 13h4"/></svg>
</span>
<span class="doc__name">National ID</span>
<span class="doc__meta">front & back</span>
</button>
</div>
<div class="tips" role="note">
<span class="tips__ico" aria-hidden="true">i</span>
<ul>
<li>Use the original, not a photocopy or screenshot.</li>
<li>All four corners visible, no glare.</li>
</ul>
</div>
<button class="btn btn--primary btn--block" data-next>Continue</button>
</section>
<!-- PANEL 1 — upload sides -->
<section class="panel" data-panel="1" hidden aria-label="Upload document">
<p class="eyebrow">Upload your <span data-doc-label>passport</span></p>
<div class="drops" data-drops>
<!-- drop zones injected by JS -->
</div>
<div class="nav">
<button class="btn btn--ghost" data-back>Back</button>
<button class="btn btn--primary" data-next disabled data-upload-next>Continue</button>
</div>
</section>
<!-- PANEL 2 — selfie / liveness -->
<section class="panel" data-panel="2" hidden aria-label="Take a selfie">
<p class="eyebrow">Liveness check</p>
<div class="selfie">
<div class="selfie__stage" data-selfie>
<div class="selfie__ring" aria-hidden="true"></div>
<div class="selfie__face" aria-hidden="true">
<svg viewBox="0 0 64 64" width="72" height="72" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><circle cx="32" cy="24" r="11"/><path d="M14 54c2-9 9-13 18-13s16 4 18 13"/></svg>
</div>
<span class="selfie__hint" data-selfie-hint>Center your face, then hold still</span>
</div>
<ul class="liveness" data-liveness>
<li data-check="frame"><span class="tick" aria-hidden="true"></span>Face in frame</li>
<li data-check="blink"><span class="tick" aria-hidden="true"></span>Blink detected</li>
<li data-check="turn"><span class="tick" aria-hidden="true"></span>Slight head turn</li>
</ul>
</div>
<div class="nav">
<button class="btn btn--ghost" data-back>Back</button>
<button class="btn btn--primary" data-selfie-start>Start liveness check</button>
</div>
</section>
<!-- PANEL 3 — scanning + result -->
<section class="panel" data-panel="3" hidden aria-label="Verification result">
<div class="scan" data-scan>
<div class="scan__doc">
<div class="scan__line" aria-hidden="true"></div>
<div class="scan__photo" aria-hidden="true"></div>
<div class="scan__lines" aria-hidden="true"><i></i><i></i><i></i></div>
</div>
<p class="scan__status" data-scan-status aria-live="polite">Matching document to selfie…</p>
<div class="bar" aria-hidden="true"><span class="bar__fill" data-scan-bar></span></div>
<ul class="scan__list" data-scan-list>
<li data-task="doc">Reading document data</li>
<li data-task="face">Comparing facial biometrics</li>
<li data-task="db">Sanctions & PEP screening</li>
</ul>
</div>
<div class="result" data-result hidden>
<div class="result__badge" data-result-badge>
<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>
</div>
<h2 class="result__title" data-result-title>You're verified</h2>
<p class="result__sub" data-result-sub>Welcome, Mara Velasquez. Your identity matches the documents provided.</p>
<dl class="result__facts" data-result-facts>
<div><dt>Document</dt><dd data-fact-doc>Passport</dd></div>
<div><dt>Match score</dt><dd><span class="pill pill--ok" data-fact-score>98% match</span></dd></div>
<div><dt>Reference</dt><dd class="mono" data-fact-ref>NB-7F3A91</dd></div>
</dl>
<div class="nav">
<button class="btn btn--ghost" data-restart>Verify again</button>
<button class="btn btn--primary" data-done>Go to dashboard</button>
</div>
</div>
</section>
</div>
</section>
<p class="legal">Illustrative demo. No documents are uploaded or stored — everything runs locally in your browser.</p>
</main>
<div class="toast-wrap" data-toast-wrap aria-live="polite" aria-atomic="true"></div>
<script src="script.js"></script>
</body>
</html>ID Verification
A four-step identity-verification flow shaped for banking and fintech onboarding, where a customer proves who they are before an account is activated. A navy header carries the bank mark and a 256-bit secure badge, and a connected stepper tracks progress through Document, Upload, Selfie, and Result. Step one offers passport, driver’s licence, and national ID as accessible radio cards, each declaring how many sides it needs and a couple of capture tips.
The upload step builds one drag-and-drop zone per side. Dropping a file — or clicking — kicks off a simulated upload with a moving progress bar, a masked card-style thumbnail, the file name and size, and a delete control; once finished, a green status pill marks it sharp and readable and the Continue button unlocks. The selfie step runs a mock liveness check with a spinning capture ring that walks through hold-still, blink, and head-turn cues, ticking each off as it passes. The final step animates a scan line across the document while it reads data, compares facial biometrics, and runs sanctions screening, then resolves to a verified card with a match score, document type, and reference code — or, occasionally, a needs-review state prompting a retake.
Everything is vanilla JS with no dependencies. Amounts and codes use tabular figures, the card number stays masked as •••• 4242, buttons and drop zones are keyboard-usable with visible focus rings, a small toast() helper confirms each action, and the layout stays legible down to 360px. Names, file sizes, and reference codes are realistic but clearly fictional, and no files ever leave the browser.
Illustrative UI only — not real banking software or financial advice.