arrow_backDocs

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.

You have built your first CrownVote voting app.

This is the smallest useful version. From here, you can add branding, candidate photos, confirmation screens, loading states, and deployment.