← code

vigenère cipher

A polyalphabetic cipher, and the classical frequency-analysis attack that breaks it — watch the key get recovered letter by letter.

694 letters

Now forget the key above — the breaker below sees only the ciphertext, and recovers the key from its letter statistics alone.

0 / 20

1. find the key length

Split the ciphertext into N interleaved groups and measure each split's Index of Coincidence — how often two random letters in a group match. Real English clusters near 0.066; a random jumble sits near 0.038. The right key length is the one whose groups look like English.

the cipher

A Vigenère cipher shifts each plaintext letter by a different amount, cycling through the letters of a keyword — so E might become P one time and K the next, depending on where it falls. Writing letters as numbers with A as 0, the whole cipher is one line:

ci  =  (pi+k i mod m) mod 26c_i \;=\; \bigl(p_i + k_{\,i \bmod m}\bigr) \bmod 26

with mm the key length — and decryption is the same expression with a minus sign. That defeats simple letter-frequency counting on the ciphertext as a whole, which is why it went unbroken for centuries and picked up the name le chiffre indéchiffrable. The scheme is misattributed, incidentally: Blaise de Vigenère described a stronger autokey version, while the repeating-keyword cipher that carries his name was published earlier by Giovan Battista Bellaso.

index of coincidence

The break has two stages, both purely statistical. First, split the ciphertext into every candidate number of interleaved groups and measure each split's index of coincidence — the probability that two letters drawn from the same group match:

IC  =  ∑i=126ni(ni−1)n(n−1)\mathrm{IC} \;=\; \frac{\sum_{i=1}^{26} n_i (n_i - 1)}{n(n-1)}

counting nin_i occurrences of each letter among nn total. English sits near 0.066 because its letters are so unevenly distributed; a uniform jumble sits at 1/26≈0.0381/26 \approx 0.038. When the group count equals the key length, every group is a plain Caesar shift of ordinary English — and shifting every letter by the same amount leaves the index untouched, so it snaps back up. Then treat each group as its own Caesar shift and pick whichever of the 26 possibilities makes that group's letter frequencies line up best with English, scored by chi-squared distance. No guessing, no dictionary — just two statistics against a reference frequency table.

kasiski and friedman

Both halves of that attack have names attached. Friedrich Kasiski published the first general method in 1863 — the Kasiski examination, which finds repeated fragments in the ciphertext and takes the greatest common divisor of the distances between them, since a repeat usually means the same plaintext met the same part of the key. Charles Babbage appears to have gotten there first, breaking a Vigenère-based challenge from his nephew around 1845, but never published it. The index of coincidence came later, from William Friedman in the 1920s, and is what this demo uses because it needs no lucky repeats — only enough text.

how much text it needs

That last condition is inherent to the technique, not a limitation of this implementation: frequency analysis needs enough ciphertext to work. A few dozen letters routinely fool it into the wrong key length; a few hundred varied letters crack cleanly almost every time. The letter counter above flags when you are in the thin-data regime, and the breaker is allowed to visibly fail there rather than pretending otherwise. Type your own message to find where the line falls — and note that highly repetitive text can still defeat it well past a few hundred letters, because the statistics care about variety, not just volume.

why it is a toy

The reason this is a toy and not a cipher is the repeating key. Stretch the key until it is as long as the message, never reuse it, and choose it at random, and the same construction becomes the one-time pad — which is provably unbreakable, because every possible plaintext of the right length is an equally valid decryption and nothing in the ciphertext prefers one over another. Everything the breaker below relies on comes from the key repeating.

view source
import { useEffect, useMemo, useRef, useState } from 'react';

// Standard English letter frequencies (source: Wikipedia's "Letter
// frequency" article, cross-checked against a web search rather than
// trusted from memory). The breaker only needs the *shape* of this
// distribution, not exact precision.
const ENGLISH_FREQ: Record<string, number> = {
  E: 12.7,
  T: 9.1,
  A: 8.2,
  O: 7.5,
  I: 7.0,
  N: 6.7,
  S: 6.3,
  H: 6.1,
  R: 6.0,
  D: 4.3,
  L: 4.0,
  C: 2.8,
  U: 2.8,
  M: 2.4,
  W: 2.4,
  F: 2.2,
  G: 2.0,
  Y: 2.0,
  P: 1.9,
  B: 1.5,
  V: 0.98,
  K: 0.77,
  J: 0.16,
  X: 0.15,
  Q: 0.12,
  Z: 0.074,
};
const A = 'A'.charCodeAt(0);
const ALPHABET = Array.from({ length: 26 }, (_, i) =>
  String.fromCharCode(A + i),
);
const FREQ_ARR = ALPHABET.map((ch) => ENGLISH_FREQ[ch] / 100);
const ENGLISH_IC = FREQ_ARR.reduce((s, p) => s + p * p, 0);
const RANDOM_IC = 1 / 26;
const MAX_KEY_LEN = 12;

function clean(text: string): string {
  return text.toUpperCase().replace(/[^A-Z]/g, '');
}

// c[i] = (p[i] + k[i mod keylen]) mod 26. Non-letters pass through
// unchanged and don't consume a key-rotation step; case is preserved
// independently of the cipher math. Verified standalone against the
// classic textbook example (ATTACKATDAWN/LEMON -> LXFOPVEFRNHR) and a
// round-trip check with mixed case/punctuation before being ported in.
function vigenereEncrypt(plaintext: string, key: string): string {
  const k = clean(key);
  if (!k) return plaintext;
  let ki = 0;
  let out = '';
  for (const ch of plaintext) {
    if (/[a-zA-Z]/.test(ch)) {
      const isUpper = ch === ch.toUpperCase();
      const p = ch.toUpperCase().charCodeAt(0) - A;
      const kc = k.charCodeAt(ki % k.length) - A;
      const c = (p + kc) % 26;
      const outCh = String.fromCharCode(A + c);
      out += isUpper ? outCh : outCh.toLowerCase();
      ki++;
    } else {
      out += ch;
    }
  }
  return out;
}

function vigenereDecrypt(ciphertext: string, key: string): string {
  const k = clean(key);
  if (!k) return ciphertext;
  let ki = 0;
  let out = '';
  for (const ch of ciphertext) {
    if (/[a-zA-Z]/.test(ch)) {
      const isUpper = ch === ch.toUpperCase();
      const c = ch.toUpperCase().charCodeAt(0) - A;
      const kc = k.charCodeAt(ki % k.length) - A;
      const p = (c - kc + 26) % 26;
      const outCh = String.fromCharCode(A + p);
      out += isUpper ? outCh : outCh.toLowerCase();
      ki++;
    } else {
      out += ch;
    }
  }
  return out;
}

// Index of Coincidence: the probability two randomly chosen letters from
// the sample are the same. Real English lands near 0.065-0.068; a
// uniformly random jumble of 26 letters lands near 1/26 = 0.0385.
// Splitting ciphertext into the correct number of interleaved groups
// (one per key letter) each becomes a plain Caesar shift of English text,
// so its IC snaps back up toward the English value; a wrong split stays
// close to random.
function indexOfCoincidence(letters: string): number {
  const counts = new Array(26).fill(0);
  for (const ch of letters) counts[ch.charCodeAt(0) - A]++;
  const n = letters.length;
  if (n < 2) return 0;
  let sum = 0;
  for (const c of counts) sum += c * (c - 1);
  return sum / (n * (n - 1));
}

interface LengthScore {
  len: number;
  avgIC: number;
}

function scoreKeyLengths(cipherLetters: string, maxLen: number): LengthScore[] {
  const scores: LengthScore[] = [];
  for (let len = 1; len <= maxLen; len++) {
    const groups: string[] = Array.from({ length: len }, () => '');
    for (let i = 0; i < cipherLetters.length; i++) {
      groups[i % len] += cipherLetters[i];
    }
    const avgIC = groups.reduce((s, g) => s + indexOfCoincidence(g), 0) / len;
    scores.push({ len, avgIC });
  }
  return scores;
}

function pickKeyLength(scores: LengthScore[]): number {
  const threshold = (RANDOM_IC + ENGLISH_IC) / 2;
  return (
    scores.find((s) => s.avgIC > threshold)?.len ??
    scores.reduce((best, s) => (s.avgIC > best.avgIC ? s : best)).len
  );
}

// Chi-squared goodness of fit between a column's observed letter counts
// (shifted back by a candidate Caesar shift) and the counts expected from
// English letter frequencies. The shift that minimizes this is the one
// most likely to be the real key letter for that column.
function chiSquaredForShift(group: string, shift: number): number {
  const counts = new Array(26).fill(0);
  for (const ch of group) {
    counts[(ch.charCodeAt(0) - A - shift + 26) % 26]++;
  }
  const n = group.length;
  let chi2 = 0;
  for (let i = 0; i < 26; i++) {
    const expected = FREQ_ARR[i] * n;
    if (expected > 0) chi2 += (counts[i] - expected) ** 2 / expected;
  }
  return chi2;
}

interface ColumnResult {
  group: string;
  shift: number;
  observed: number[];
}

function analyzeColumn(group: string): ColumnResult {
  let best = 0;
  let bestChi2 = Infinity;
  for (let shift = 0; shift < 26; shift++) {
    const chi2 = chiSquaredForShift(group, shift);
    if (chi2 < bestChi2) {
      bestChi2 = chi2;
      best = shift;
    }
  }
  const observed = new Array(26).fill(0);
  for (const ch of group) {
    observed[(ch.charCodeAt(0) - A - best + 26) % 26]++;
  }
  return { group, shift: best, observed };
}

function splitColumns(cipherLetters: string, keyLen: number): string[] {
  const groups: string[] = Array.from({ length: keyLen }, () => '');
  for (let i = 0; i < cipherLetters.length; i++) {
    groups[i % keyLen] += cipherLetters[i];
  }
  return groups;
}

const DEFAULT_PLAINTEXT =
  'In cryptography, a Vigenere cipher uses a simple form of polyalphabetic ' +
  'substitution. A table of alphabets can be used together with a keyword ' +
  'to encrypt a message: each letter of the keyword is used in turn to ' +
  'select an alphabet for encrypting one letter of the plaintext. For a ' +
  'long time this type of cipher was considered unbreakable, but Charles ' +
  'Babbage and Friedrich Kasiski eventually showed how frequency analysis ' +
  'could recover the key by examining repeated fragments and counting ' +
  'letters in each column of the ciphertext. This demonstration shows that ' +
  'process happening step by step, so the attack is easy to follow along. ' +
  'A longer passage like this one gives the index of coincidence and chi ' +
  'squared statistics enough letters per column to lock onto the true key ' +
  'with confidence, the same way it worked for codebreakers a century ago.';

const DEFAULT_KEY = 'CIPHER';

// Frequency analysis genuinely needs enough ciphertext to work -- this
// isn't a limitation of this implementation, it's inherent to the
// technique. Verified standalone: short messages (a few dozen letters)
// regularly produce a wrong key length or garbled key, while a varied
// passage of a few hundred letters cracks cleanly. Below this rough
// letter count, warn rather than pretend it always works.
const RELIABLE_LETTER_COUNT = 200;

type Phase = 'idle' | 'length' | 'columns' | 'done';

export default function VigenereCipher() {
  const [plaintext, setPlaintext] = useState(DEFAULT_PLAINTEXT);
  const [key, setKey] = useState(DEFAULT_KEY);

  const cipher = useMemo(
    () => vigenereEncrypt(plaintext, key),
    [plaintext, key],
  );
  const cipherLetters = useMemo(() => clean(cipher), [cipher]);

  const lengthScores = useMemo(
    () => scoreKeyLengths(cipherLetters, MAX_KEY_LEN),
    [cipherLetters],
  );
  const chosenLen = useMemo(() => pickKeyLength(lengthScores), [lengthScores]);
  const columns = useMemo(
    () => splitColumns(cipherLetters, chosenLen).map(analyzeColumn),
    [cipherLetters, chosenLen],
  );
  const guessedKey = useMemo(
    () => columns.map((c) => String.fromCharCode(A + c.shift)).join(''),
    [columns],
  );
  const decrypted = useMemo(
    () => vigenereDecrypt(cipher, guessedKey),
    [cipher, guessedKey],
  );

  const [phase, setPhase] = useState<Phase>('idle');
  const [lengthStep, setLengthStep] = useState(0); // how many length bars revealed
  const [columnStep, setColumnStep] = useState(0); // how many columns revealed
  const [playing, setPlaying] = useState(false);
  const [speed, setSpeed] = useState(220);
  const timerRef = useRef<number | null>(null);

  // Any edit to the ciphertext (via plaintext/key) invalidates a run in
  // progress.
  useEffect(() => {
    setPhase('idle');
    setLengthStep(0);
    setColumnStep(0);
    setPlaying(false);
  }, [cipherLetters]);

  const totalSteps = MAX_KEY_LEN + 1 + chosenLen + 1;
  const currentStep =
    phase === 'idle'
      ? 0
      : phase === 'length'
        ? lengthStep
        : phase === 'columns'
          ? MAX_KEY_LEN + 1 + columnStep
          : totalSteps;

  const advance = () => {
    if (phase === 'idle') {
      setPhase('length');
      setLengthStep(1);
      return;
    }
    if (phase === 'length') {
      if (lengthStep < MAX_KEY_LEN) {
        setLengthStep((s) => s + 1);
      } else {
        setPhase('columns');
        setColumnStep(chosenLen > 0 ? 1 : 0);
        if (chosenLen === 0) setPhase('done');
      }
      return;
    }
    if (phase === 'columns') {
      if (columnStep < chosenLen) {
        setColumnStep((s) => s + 1);
      } else {
        setPhase('done');
      }
      return;
    }
  };

  const running = phase !== 'done';

  useEffect(() => {
    if (!playing) return;
    if (!running) {
      setPlaying(false);
      return;
    }
    timerRef.current = window.setTimeout(advance, speed);
    return () => {
      if (timerRef.current) clearTimeout(timerRef.current);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [playing, phase, lengthStep, columnStep, speed, running]);

  const reset = () => {
    setPlaying(false);
    setPhase('idle');
    setLengthStep(0);
    setColumnStep(0);
  };

  const maxIC = Math.max(ENGLISH_IC, ...lengthScores.map((s) => s.avgIC));
  const shortWarning = cipherLetters.length < RELIABLE_LETTER_COUNT;

  return (
    <div className="vigenere">
      <div className="codec">
        <div className="field">
          <label htmlFor="vig-plaintext">plaintext</label>
          <textarea
            id="vig-plaintext"
            value={plaintext}
            onChange={(e) => setPlaintext(e.target.value)}
            spellCheck={false}
            rows={4}
          />
        </div>
        <div className="field key-field">
          <label htmlFor="vig-key">key</label>
          <input
            id="vig-key"
            type="text"
            value={key}
            onChange={(e) => setKey(e.target.value)}
            spellCheck={false}
          />
          <span className="letter-count">
            {cipherLetters.length} letters
            {shortWarning ? ' — short text is harder to break' : ''}
          </span>
        </div>
        <div className="field">
          <label htmlFor="vig-cipher">ciphertext</label>
          <textarea id="vig-cipher" value={cipher} readOnly rows={4} />
        </div>
      </div>

      <div className="breaker">
        <p className="breaker-intro">
          Now forget the key above — the breaker below sees only the ciphertext,
          and recovers the key from its letter statistics alone.
        </p>

        <div className="controls">
          <button type="button" onClick={advance} disabled={!running}>
            step
          </button>
          <button
            type="button"
            onClick={() => setPlaying((p) => !p)}
            disabled={!running && !playing}
          >
            {playing ? 'pause' : 'play'}
          </button>
          <button type="button" onClick={reset}>
            reset
          </button>
          <label className="speed">
            speed
            <input
              type="range"
              min={40}
              max={500}
              step={20}
              value={540 - speed}
              onChange={(e) => setSpeed(540 - Number(e.target.value))}
            />
          </label>
          <span className="progress">
            {currentStep} / {totalSteps}
          </span>
        </div>

        <section className="stage">
          <h3>1. find the key length</h3>
          <p className="stage-note">
            Split the ciphertext into <em>N</em> interleaved groups and measure
            each split's Index of Coincidence — how often two random letters in
            a group match. Real English clusters near {ENGLISH_IC.toFixed(3)}; a
            random jumble sits near {RANDOM_IC.toFixed(3)}. The right key length
            is the one whose groups look like English.
          </p>
          <div
            className="ic-chart"
            role="img"
            aria-label="index of coincidence by candidate key length"
          >
            {lengthScores.map((s) => {
              const revealed = s.len <= lengthStep;
              const isChosen =
                phase !== 'idle' && phase !== 'length' && s.len === chosenLen;
              const heightPct = revealed ? (s.avgIC / maxIC) * 100 : 0;
              return (
                <div className="ic-bar-col" key={s.len}>
                  <div className="ic-bar-track">
                    <div
                      className={isChosen ? 'ic-bar chosen' : 'ic-bar'}
                      style={{ height: `${heightPct}%` }}
                    />
                  </div>
                  <span className="ic-bar-label">{s.len}</span>
                </div>
              );
            })}
          </div>
          {phase !== 'idle' && lengthStep >= MAX_KEY_LEN && (
            <p className="conclusion">
              detected key length: <strong>{chosenLen}</strong>
            </p>
          )}
        </section>

        {phase !== 'idle' && lengthStep >= MAX_KEY_LEN && (
          <section className="stage">
            <h3>2. recover each key letter</h3>
            <p className="stage-note">
              Every {chosenLen}
              {chosenLen === 1 ? 'st' : 'th'} letter of the ciphertext was
              shifted by the same key letter — a plain Caesar cipher. Try all 26
              shifts against each column and keep the one whose letter
              frequencies line up best with English (lowest chi-squared).
            </p>
            <div className="columns-grid">
              {columns.map((col, i) => {
                const revealed = i < columnStep;
                return (
                  <div className="column-card" key={i}>
                    <div className="column-header">
                      <span>col {i + 1}</span>
                      <span className="column-letter">
                        {revealed ? String.fromCharCode(A + col.shift) : '?'}
                      </span>
                    </div>
                    <div className="freq-chart">
                      {ALPHABET.map((letter, j) => {
                        const observedPct = revealed
                          ? col.observed[j] / col.group.length
                          : 0;
                        const expectedPct = FREQ_ARR[j];
                        const maxPct = 0.16;
                        return (
                          <div className="freq-col" key={letter}>
                            <div className="freq-track">
                              <div
                                className="freq-expected"
                                style={{
                                  height: `${(expectedPct / maxPct) * 100}%`,
                                }}
                              />
                              {revealed && (
                                <div
                                  className="freq-observed"
                                  style={{
                                    height: `${(observedPct / maxPct) * 100}%`,
                                  }}
                                />
                              )}
                            </div>
                          </div>
                        );
                      })}
                    </div>
                  </div>
                );
              })}
            </div>
            <p className="key-so-far">
              recovered key:{' '}
              <strong>
                {columns
                  .map((c, i) =>
                    i < columnStep ? String.fromCharCode(A + c.shift) : '_',
                  )
                  .join('')}
              </strong>
            </p>
          </section>
        )}

        {phase === 'done' && (
          <section className="stage result">
            <h3>3. result</h3>
            <p>
              guessed key: <strong>{guessedKey}</strong>{' '}
              {guessedKey === clean(key) ? (
                <span className="match">matches the real key</span>
              ) : (
                <span className="mismatch">doesn't match the real key</span>
              )}
            </p>
            <div className="field">
              <label htmlFor="vig-decrypted">
                decrypted with the guessed key
              </label>
              <textarea
                id="vig-decrypted"
                value={decrypted}
                readOnly
                rows={4}
              />
            </div>
          </section>
        )}
      </div>

      <style>{`
        .vigenere {
          border: 1px solid #2a2d31;
          background: #0e1113;
          padding: 1.25rem 1rem;
        }
        .vigenere .field {
          display: flex;
          flex-direction: column;
          gap: 0.3rem;
          margin-bottom: 0.9rem;
        }
        .vigenere .field label {
          color: var(--muted);
          font-size: 0.75rem;
          text-transform: uppercase;
          letter-spacing: 0.04em;
        }
        .vigenere textarea,
        .vigenere input[type='text'] {
          font-family: var(--mono);
          font-size: 0.85rem;
          color: var(--fg);
          background: rgba(0, 0, 0, 0.4);
          border: 1px solid #2a2d31;
          padding: 0.5rem 0.6rem;
          resize: vertical;
          width: 100%;
          box-sizing: border-box;
        }
        .vigenere .key-field {
          flex-direction: row;
          align-items: center;
          flex-wrap: wrap;
        }
        .vigenere .key-field label {
          margin-right: 0.5rem;
        }
        .vigenere .key-field input {
          width: auto;
          flex: 0 1 12rem;
        }
        .vigenere .letter-count {
          color: var(--muted);
          font-size: 0.75rem;
          margin-left: 0.75rem;
        }
        .vigenere .breaker {
          margin-top: 1.25rem;
          padding-top: 1rem;
          border-top: 1px solid #2a2d31;
        }
        .vigenere .breaker-intro {
          color: var(--muted);
          font-size: 0.85rem;
          margin: 0 0 1rem;
        }
        .vigenere .controls {
          display: flex;
          flex-wrap: wrap;
          align-items: center;
          gap: 0.75rem;
          margin-bottom: 1.25rem;
        }
        .vigenere button {
          font-family: inherit;
          font-size: 0.75rem;
          color: #8a8f98;
          background: rgba(0, 0, 0, 0.4);
          border: 1px solid #2a2d31;
          padding: 0.25rem 0.6rem;
          cursor: pointer;
        }
        .vigenere button:hover:not(:disabled) {
          color: #e6e6e6;
        }
        .vigenere button:disabled {
          opacity: 0.4;
          cursor: default;
        }
        .vigenere .speed {
          display: flex;
          align-items: center;
          gap: 0.5rem;
          color: var(--muted);
          font-size: 0.75rem;
        }
        .vigenere .progress {
          color: var(--muted);
          font-size: 0.75rem;
          margin-left: auto;
        }
        .vigenere .stage {
          margin-bottom: 1.5rem;
        }
        .vigenere .stage h3 {
          font-size: 0.85rem;
          font-weight: normal;
          color: var(--fg);
          margin: 0 0 0.4rem;
        }
        .vigenere .stage-note {
          color: var(--muted);
          font-size: 0.8rem;
          margin: 0 0 0.9rem;
        }
        .vigenere .stage-note em {
          color: var(--fg);
          font-style: normal;
        }
        .vigenere .ic-chart {
          display: flex;
          align-items: flex-end;
          gap: 0.4rem;
          height: 8rem;
          border-bottom: 1px solid #2a2d31;
          padding-bottom: 0;
        }
        .vigenere .ic-bar-col {
          display: flex;
          flex-direction: column;
          align-items: center;
          flex: 1;
          height: 100%;
          justify-content: flex-end;
          gap: 0.3rem;
        }
        .vigenere .ic-bar-track {
          width: 100%;
          height: 100%;
          display: flex;
          align-items: flex-end;
        }
        .vigenere .ic-bar {
          width: 100%;
          background: #3a3f45;
          transition: height 0.15s ease-out;
        }
        .vigenere .ic-bar.chosen {
          background: var(--accent);
        }
        .vigenere .ic-bar-label {
          color: var(--muted);
          font-size: 0.7rem;
        }
        .vigenere .conclusion {
          margin: 0.75rem 0 0;
          font-size: 0.85rem;
          color: var(--muted);
        }
        .vigenere .conclusion strong {
          color: var(--accent);
          font-weight: normal;
        }
        .vigenere .columns-grid {
          display: grid;
          grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
          gap: 0.75rem;
        }
        .vigenere .column-card {
          border: 1px solid #2a2d31;
          padding: 0.5rem;
        }
        .vigenere .column-header {
          display: flex;
          justify-content: space-between;
          font-size: 0.75rem;
          color: var(--muted);
          margin-bottom: 0.4rem;
        }
        .vigenere .column-letter {
          color: var(--accent);
          font-size: 0.9rem;
        }
        .vigenere .freq-chart {
          display: flex;
          align-items: flex-end;
          gap: 1px;
          height: 3rem;
        }
        .vigenere .freq-col {
          flex: 1;
          height: 100%;
          display: flex;
          align-items: flex-end;
        }
        .vigenere .freq-track {
          position: relative;
          width: 100%;
          height: 100%;
        }
        .vigenere .freq-expected {
          position: absolute;
          bottom: 0;
          width: 100%;
          background: #2a2d31;
        }
        .vigenere .freq-observed {
          position: absolute;
          bottom: 0;
          width: 60%;
          left: 20%;
          background: var(--accent);
          opacity: 0.85;
        }
        .vigenere .key-so-far {
          margin: 1rem 0 0;
          font-size: 0.9rem;
          color: var(--muted);
          letter-spacing: 0.1em;
        }
        .vigenere .key-so-far strong {
          color: var(--fg);
          font-weight: normal;
        }
        .vigenere .result p {
          font-size: 0.85rem;
          color: var(--muted);
        }
        .vigenere .result strong {
          color: var(--fg);
          font-weight: normal;
        }
        .vigenere .match {
          color: var(--accent);
        }
        .vigenere .mismatch {
          color: #d98a8a;
        }
      `}</style>
    </div>
  );
}