Final Step
Full code.
Here is the complete minimal remote voting app. Copy the three files into your my-election folder, replace the public key, and open the page in your browser.
Your project is complete.
The app now loads the election, authenticates the voter, renders ballots, collects selections, and submits encrypted votes.
code Project structure
my-election/
├── index.html
├── index.css
└── index.js
Open index.html in your browser.
For production, upload the folder to any static host:
- Cloudflare Pages
- Vercel
- Netlify
- GitHub Pages
- Your own VPS code index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>My Election</title>
<link rel="stylesheet" href="./index.css" />
</head>
<body>
<main class="app">
<section class="card">
<p class="eyebrow">CrownVote API Voting</p>
<h1 id="electionTitle">Loading election...</h1>
<p id="electionDescription" class="muted">
Please wait while we load the election.
</p>
<div id="notice" class="notice hidden"></div>
<form id="loginForm" class="form hidden">
<label>
Voter ID
<input id="voterId" type="text" placeholder="Enter voter ID" required />
</label>
<label>
Password
<input id="password" type="password" placeholder="Enter password" required />
</label>
<button type="submit">Login to vote</button>
</form>
<section id="ballotScreen" class="hidden">
<div id="voterInfo" class="voter"></div>
<div id="ballots" class="ballots"></div>
<button id="submitVote" type="button">Submit vote</button>
</section>
</section>
</main>
<script src="./index.js"></script>
</body>
</html> code index.css
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f4f7fb;
color: #101828;
}
.app {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
}
.card {
width: 100%;
max-width: 760px;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 28px;
padding: 32px;
box-shadow: 0 20px 60px rgba(16, 24, 40, 0.08);
}
.eyebrow {
margin: 0 0 12px;
color: #2563eb;
font-weight: 700;
font-size: 14px;
}
h1 {
margin: 0;
font-size: clamp(30px, 5vw, 48px);
letter-spacing: -0.04em;
}
h2 {
margin: 0;
font-size: 20px;
}
.muted {
color: #667085;
line-height: 1.7;
}
.form {
margin-top: 28px;
display: grid;
gap: 16px;
}
label {
display: grid;
gap: 8px;
font-size: 14px;
font-weight: 700;
}
input {
width: 100%;
border: 1px solid #d0d5dd;
border-radius: 14px;
padding: 14px 16px;
font: inherit;
}
button {
border: 0;
border-radius: 999px;
padding: 14px 18px;
background: #2563eb;
color: white;
font-weight: 800;
cursor: pointer;
}
button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.notice {
margin-top: 20px;
padding: 14px 16px;
border-radius: 16px;
background: #eff6ff;
color: #1d4ed8;
font-size: 14px;
line-height: 1.6;
}
.voter {
margin-top: 24px;
padding: 14px 16px;
border-radius: 16px;
background: #f9fafb;
color: #344054;
font-weight: 700;
}
.ballots {
margin-top: 24px;
display: grid;
gap: 24px;
}
.ballot {
border: 1px solid #e5e7eb;
border-radius: 24px;
padding: 20px;
background: #ffffff;
}
.ballot-header {
margin-bottom: 18px;
}
.ballot h2 {
margin: 0;
font-size: 22px;
letter-spacing: -0.02em;
}
.candidates {
display: grid;
gap: 14px;
}
.candidate {
position: relative;
display: grid;
grid-template-columns: auto 56px 1fr auto;
align-items: center;
gap: 14px;
border: 1px solid #e5e7eb;
border-radius: 18px;
padding: 14px;
cursor: pointer;
background: #ffffff;
transition:
border-color 0.2s ease,
background 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s ease;
}
.candidate:hover {
border-color: #2563eb;
background: #eff6ff;
transform: translateY(-1px);
box-shadow: 0 12px 30px rgba(37, 99, 235, 0.08);
}
.candidate.selected {
border-color: #2563eb;
background: #eff6ff;
box-shadow: 0 12px 30px rgba(37, 99, 235, 0.12);
}
.candidate input {
width: 18px;
height: 18px;
accent-color: #2563eb;
}
.candidate-image {
width: 56px;
height: 56px;
overflow: hidden;
border-radius: 16px;
background: #dbeafe;
color: #2563eb;
display: grid;
place-items: center;
font-weight: 900;
}
.candidate-image img {
width: 100%;
height: 100%;
object-fit: cover;
}
.candidate-content {
display: grid;
gap: 4px;
}
.candidate-content strong {
font-size: 15px;
color: #101828;
}
.candidate-content small {
color: #667085;
line-height: 1.4;
}
.candidate-check {
width: 28px;
height: 28px;
border-radius: 999px;
display: grid;
place-items: center;
background: #2563eb;
color: #ffffff;
font-size: 14px;
font-weight: 900;
opacity: 0;
transform: scale(0.85);
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.candidate.selected .candidate-check {
opacity: 1;
transform: scale(1);
}
@media (max-width: 520px) {
.candidate {
grid-template-columns: auto 48px 1fr;
}
.candidate-image {
width: 48px;
height: 48px;
border-radius: 14px;
}
.candidate-check {
display: none;
}
}
#submitVote {
width: 100%;
margin-top: 24px;
}
.hidden {
display: none;
} code index.js
const API_URL = 'https://api.crownvote.com/api/v1'
const PUBLIC_KEY = 'cv_pub_xxxxx'
const state = {
election: null,
accessToken: null,
ballots: [],
selections: {},
}
const elements = {
title: document.querySelector('#electionTitle'),
description: document.querySelector('#electionDescription'),
notice: document.querySelector('#notice'),
loginForm: document.querySelector('#loginForm'),
voterId: document.querySelector('#voterId'),
password: document.querySelector('#password'),
ballotScreen: document.querySelector('#ballotScreen'),
voterInfo: document.querySelector('#voterInfo'),
ballots: document.querySelector('#ballots'),
submitVote: document.querySelector('#submitVote'),
}
function showNotice(message) {
elements.notice.textContent = message
elements.notice.classList.remove('hidden')
}
function hideNotice() {
elements.notice.textContent = ''
elements.notice.classList.add('hidden')
}
async function getElection() {
hideNotice()
const response = await fetch(`${API_URL}/election`, {
headers: {
'X-PUBLIC-KEY': PUBLIC_KEY,
},
})
const result = await response.json()
if (!result.status) {
showNotice(result.message || 'Unable to load election.')
return
}
state.election = result.data.election
elements.title.textContent = state.election.title
elements.description.textContent =
state.election.description || state.election.instructions || 'Login to cast your vote.'
if (state.election.votingMode !== 'remote') {
showNotice('This starter guide is for remote voting only.')
return
}
if (state.election.accessMode !== 'api') {
showNotice('This election is not configured for API voting.')
return
}
if (state.election.status !== 'live') {
showNotice('This election is not currently live.')
return
}
elements.loginForm.classList.remove('hidden')
}
async function loginVoter(event) {
event.preventDefault()
hideNotice()
const voterId = elements.voterId.value.trim()
const password = elements.password.value.trim()
if (!voterId || !password) {
showNotice('Enter your voter ID and password.')
return
}
const response = await fetch(`${API_URL}/voter/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-PUBLIC-KEY': PUBLIC_KEY,
},
body: JSON.stringify({
voterId,
password,
}),
})
const result = await response.json()
if (!result.status) {
showNotice(result.message || 'Voter login failed.')
return
}
state.accessToken = result.data.accessToken.value
state.ballots = result.data.ballots
state.selections = {}
sessionStorage.setItem('crownvote_voter_token', state.accessToken)
elements.loginForm.classList.add('hidden')
elements.ballotScreen.classList.remove('hidden')
elements.voterInfo.textContent = `Voting as ${result.data.voter.displayName}`
renderBallots()
}
function renderBallots() {
elements.ballots.innerHTML = ''
state.selections = {}
state.ballots.forEach((ballot) => {
const ballotElement = document.createElement('section')
ballotElement.className = 'ballot'
const header = document.createElement('div')
header.className = 'ballot-header'
const title = document.createElement('h2')
title.textContent = ballot.title
const instruction = document.createElement('p')
instruction.className = 'muted'
instruction.textContent = ballot.instructions || 'Select one candidate.'
header.appendChild(title)
header.appendChild(instruction)
const candidates = document.createElement('div')
candidates.className = 'candidates'
ballot.candidates.forEach((candidate) => {
const label = document.createElement('label')
label.className = 'candidate'
const input = document.createElement('input')
input.type = 'radio'
input.name = ballot.categoryId
input.value = candidate.vote
const imageWrap = document.createElement('div')
imageWrap.className = 'candidate-image'
if (candidate.image) {
const image = document.createElement('img')
image.src = candidate.image
image.alt = candidate.name
image.loading = 'lazy'
imageWrap.appendChild(image)
} else {
const fallback = document.createElement('span')
fallback.textContent = candidate.name.charAt(0).toUpperCase()
imageWrap.appendChild(fallback)
}
const content = document.createElement('div')
content.className = 'candidate-content'
const name = document.createElement('strong')
name.textContent = candidate.name
const meta = document.createElement('small')
meta.textContent = candidate.bio || candidate.manifesto || 'Candidate'
content.appendChild(name)
content.appendChild(meta)
const check = document.createElement('span')
check.className = 'candidate-check'
check.textContent = '✓'
input.addEventListener('change', () => {
state.selections[ballot.categoryId] = candidate.vote
const siblings = candidates.querySelectorAll('.candidate')
siblings.forEach((item) => item.classList.remove('selected'))
label.classList.add('selected')
})
label.appendChild(input)
label.appendChild(imageWrap)
label.appendChild(content)
label.appendChild(check)
candidates.appendChild(label)
})
ballotElement.appendChild(header)
ballotElement.appendChild(candidates)
elements.ballots.appendChild(ballotElement)
})
}
async function submitVote() {
hideNotice()
const selections = state.ballots.map((ballot) => {
return {
categoryId: ballot.categoryId,
vote: state.selections[ballot.categoryId],
}
})
const missingSelection = selections.find((selection) => !selection.vote)
if (missingSelection) {
showNotice('Please select one candidate for every ballot category.')
return
}
elements.submitVote.disabled = true
elements.submitVote.textContent = 'Submitting...'
const response = await fetch(`${API_URL}/voter/submit-votes`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-PUBLIC-KEY': PUBLIC_KEY,
Authorization: `Bearer ${state.accessToken}`,
},
body: JSON.stringify({ selections }),
})
const result = await response.json()
if (!result.status) {
elements.submitVote.disabled = false
elements.submitVote.textContent = 'Submit vote'
showNotice(result.message || 'Vote submission failed.')
return
}
sessionStorage.removeItem('crownvote_voter_token')
elements.ballotScreen.classList.add('hidden')
showNotice('Your vote has been submitted successfully.')
}
elements.loginForm.addEventListener('submit', loginVoter)
elements.submitVote.addEventListener('click', submitVote)
getElection()Make it yours.
check_circle Replace cv_pub_xxxxx with your real election public key.
check_circle Customize the title, brand colors, spacing, and button styles.
check_circle Deploy the folder to any static hosting provider.
check_circle Use the API reference when you need in-person voting, admin sessions, or submission status checks.