This guide builds an embeddable currency converter widget in plain JavaScript that ships with dark mode, follows the visitor's system theme, and drops into any page with two lines of HTML. No React. No Vue. No build step.

It also fixes the mistake most JavaScript currency converter tutorials make. They pass a base parameter on every request, which returns an error on free API plans and silently produces wrong numbers when the visitor picks a non-USD source currency. The version below pulls one USD-based payload and derives every pair locally, so it runs correctly on the free CurrencyFreaks Developer plan and costs one API call per page load instead of one per keystroke.

Copy the embed snippet if you want the widget now. Read Steps 1 to 4 if you want to understand or restyle it.

Check out this tutorial on creating a currency widget using React JS

Quick answer

An embeddable currency converter widget is a self-contained block of HTML, CSS and JavaScript that fetches live exchange rates from a REST API and converts an amount between two currencies inside any host page. Dark mode is handled with CSS custom properties: define the light palette on :root, redefine the same tokens under a prefers-color-scheme: dark media query, and let a data-cf-theme attribute override both so a manual toggle still wins.

Paste-In Embed Code for the Currency Converter Widget

Two lines of HTML, once you have self-hosted a single script file. Drop the container where the widget should appear, then load the script once before the closing body tag.

<!-- 1. Where the widget renders -->
<div data-cf-converter data-from="USD" data-to="EUR" data-amount="100" data-theme="auto"></div>
 
<!-- 2. Load once, anywhere before </body> -->
<script src="/js/cf-converter.js" defer></script>

The four attributes control everything the host page needs:

AttributeValuesDefaultEffect
data-fromISO 4217 codeUSDSource currency preselected in the dropdown
data-toISO 4217 codeEURTarget currency preselected in the dropdown
data-amountNumber100Starting amount in the input
data-themeauto, light, darkautoauto follows the visitor's system setting, the other two pin the palette

Here is the whole of cf-converter.js. It injects its own styles once, supports several instances on one page, and makes exactly one network request no matter how many widgets are on screen.

// cf-converter.js
// Self-host this file, then add the two lines above to any page.
// Returns: renders every [data-cf-converter] block and converts pairs client side.
(function () {
  const API_KEY = 'YOUR_API_KEY';
  const ENDPOINT = 'https://api.currencyfreaks.com/v2.0/rates/latest';
 
  const hosts = document.querySelectorAll('[data-cf-converter]');
  if (!hosts.length) return;
 
  injectStyles();
  const ratesPromise = fetch(`${ENDPOINT}?apikey=${API_KEY}`)
    .then(r => {
      if (!r.ok) throw new Error(`CurrencyFreaks responded ${r.status}`);
      return r.json();
    })
    .then(payload => payload.rates);
 
  hosts.forEach((host, index) => mount(host, index));
 
  function mount(host, index) {
    const uid = `cf${index}`;
    const startFrom = (host.dataset.from || 'USD').toUpperCase();
    const startTo = (host.dataset.to || 'EUR').toUpperCase();
    const startAmount = host.dataset.amount || '100';
 
    host.setAttribute('data-cf-theme', host.dataset.theme || 'auto');
    host.innerHTML = `
      <div class="cf-card">
        <div class="cf-head">
          <span class="cf-title">Currency converter</span>
          <button type="button" class="cf-toggle" aria-pressed="false">Dark</button>
        </div>
        <label class="cf-label" for="${uid}-amount">Amount</label>
        <input class="cf-field" id="${uid}-amount" type="number" min="0" step="any" value="${startAmount}">
        <label class="cf-label" for="${uid}-from">From</label>
        <select class="cf-field" id="${uid}-from"></select>
        <label class="cf-label" for="${uid}-to">To</label>
        <select class="cf-field" id="${uid}-to"></select>
        <output class="cf-result" aria-live="polite">Loading rates</output>
      </div>`;
 
    const amount = host.querySelector(`#${uid}-amount`);
    const from = host.querySelector(`#${uid}-from`);
    const to = host.querySelector(`#${uid}-to`);
    const result = host.querySelector('.cf-result');
    const toggle = host.querySelector('.cf-toggle');
 
    let rates = null;
 
    ratesPromise
      .then(payload => {
        rates = payload;
        // USD is the base currency, so the API omits it from `rates` - add it back for the dropdowns.
        const codes = Object.keys(rates).concat('USD').sort();
        codes.forEach(code => {
          from.add(new Option(code, code));
          to.add(new Option(code, code));
        });
        from.value = (rates[startFrom] || startFrom === 'USD') ? startFrom : 'USD';
        to.value = (rates[startTo] || startTo === 'USD') ? startTo : 'EUR';
        [amount, from, to].forEach(el => el.addEventListener('input', convert));
        convert();
      })
      .catch(error => {
        console.error(error);
        result.textContent = 'Exchange rates unavailable right now.';
      });
 
    toggle.addEventListener('click', () => {
      const dark = resolveTheme(host) === 'dark';
      host.setAttribute('data-cf-theme', dark ? 'light' : 'dark');
      paintToggle();
    });
 
    paintToggle();
 
    function paintToggle() {
      const dark = resolveTheme(host) === 'dark';
      toggle.textContent = dark ? 'Light' : 'Dark';
      toggle.setAttribute('aria-pressed', String(dark));
    }
 
    function convert() {
      if (!rates) return;
      const value = parseFloat(amount.value);
      const fromRate = rateOf(rates, from.value);
      const toRate = rateOf(rates, to.value);
 
      if (!Number.isFinite(value) || fromRate === null || toRate === null) {
        result.textContent = '';
        return;
      }
      // USD is the pivot, so FROM to TO is simply toRate divided by fromRate.
      const converted = (value * (toRate / fromRate)).toFixed(4);
      result.textContent = `${value} ${from.value} = ${converted} ${to.value}`;
    }
  }
 
  function rateOf(rates, code) {
    if (code === 'USD') return 1;
    const value = parseFloat(rates[code]);
    return Number.isFinite(value) ? value : null;
  }
 
  function resolveTheme(host) {
    const set = host.getAttribute('data-cf-theme');
    if (set === 'dark' || set === 'light') return set;
    return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
  }
 
  function injectStyles() {
    if (document.getElementById('cf-converter-styles')) return;
    const style = document.createElement('style');
    style.id = 'cf-converter-styles';
    style.textContent = `
      [data-cf-converter]{--cf-surface:#ffffff;--cf-text:#1f2937;--cf-muted:#4b5563;
        --cf-border:#d1d5db;--cf-field:#f1f5f9;--cf-accent:#0f7d6f;--cf-shadow:rgba(0,0,0,.15)}
      @media (prefers-color-scheme: dark){
        [data-cf-converter]:not([data-cf-theme="light"]){--cf-surface:#131c2e;--cf-text:#e5edf5;
          --cf-muted:#9fb0c3;--cf-border:#2a3a54;--cf-field:#1b2740;--cf-accent:#14b8b8;
          --cf-shadow:rgba(0,0,0,.55)}}
      [data-cf-converter][data-cf-theme="dark"]{--cf-surface:#131c2e;--cf-text:#e5edf5;
        --cf-muted:#9fb0c3;--cf-border:#2a3a54;--cf-field:#1b2740;--cf-accent:#14b8b8;
        --cf-shadow:rgba(0,0,0,.55)}
      .cf-card{max-width:360px;padding:24px;border-radius:14px;background:var(--cf-surface);
        color:var(--cf-text);border:1px solid var(--cf-border);box-shadow:0 8px 20px var(--cf-shadow);
        font-family:system-ui,-apple-system,'Segoe UI',sans-serif}
      .cf-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:18px}
      .cf-title{font-weight:700;font-size:1.05em}
      .cf-toggle{padding:6px 12px;border-radius:999px;border:1px solid var(--cf-border);
        background:transparent;color:var(--cf-muted);font-size:.8em;cursor:pointer}
      .cf-label{display:block;margin-bottom:6px;font-size:.85em;color:var(--cf-muted)}
      .cf-field{width:100%;box-sizing:border-box;padding:11px;margin-bottom:16px;
        border:1px solid var(--cf-border);border-radius:8px;background:var(--cf-field);
        color:var(--cf-text);font-size:1em}
      .cf-field:focus{outline:2px solid var(--cf-accent);outline-offset:1px}
      .cf-result{display:block;margin-top:6px;font-size:1.25em;font-weight:700;color:var(--cf-text)}
    `;
    document.head.appendChild(style);
  }
})();

Swap YOUR_API_KEY for the key on your CurrencyFreaks account and the widget is live. The dropdowns list every currency the Latest Rates endpoint returns, so coverage matches the supported currencies list exactly.

Building a Currency Exchange Rate Widget

The four steps below build the same widget from scratch so you can restyle it, change the layout, or strip out the parts you do not need. Step 2 is where dark mode lives.

Step 1: Basic HTML Structure

Start by creating the basic structure of the HTML document.

<!DOCTYPE html>

<html lang="en">

<head>

    <meta charset="UTF-8">

    <meta name="viewport" content="width=device-width, initial-scale=1.0">

    <title>Currency Exchange Rate Widget</title>

Step 2: Adding CSS for Styling

Every color goes into a CSS custom property instead of being hardcoded. That single decision is what makes dark mode possible later without touching a line of JavaScript. Define the light palette on :root, redefine the same token names inside a prefers-color-scheme: dark media query, then redefine them once more under a [data-cf-theme="dark"] attribute so a manual toggle can override the system setting in both directions.

<style>
  /* Light palette. Every color the widget uses is declared once, here. */
  :root {
    color-scheme: light dark;
    --cf-page: #e8eef3;
    --cf-surface: #ffffff;
    --cf-text: #1f2937;
    --cf-muted: #4b5563;
    --cf-border: #d1d5db;
    --cf-field: #f1f5f9;
    --cf-accent: #0f7d6f;
    --cf-accent-hover: #13968a;
    --cf-on-accent: #ffffff;
    --cf-shadow: rgba(0, 0, 0, 0.15);
  }
 
  /* Dark palette when the visitor's OS asks for it, unless light is pinned. */
  @media (prefers-color-scheme: dark) {
    :root:not([data-cf-theme="light"]) {
      --cf-page: #070d18;
      --cf-surface: #131c2e;
      --cf-text: #e5edf5;
      --cf-muted: #9fb0c3;
      --cf-border: #2a3a54;
      --cf-field: #1b2740;
      --cf-accent: #14b8b8;
      --cf-accent-hover: #3ad3d3;
      --cf-on-accent: #06201f;
      --cf-shadow: rgba(0, 0, 0, 0.55);
    }
  }
 
  /* Manual toggle. Wins over the media query in both directions. */
  :root[data-cf-theme="dark"] {
    --cf-page: #070d18;
    --cf-surface: #131c2e;
    --cf-text: #e5edf5;
    --cf-muted: #9fb0c3;
    --cf-border: #2a3a54;
    --cf-field: #1b2740;
    --cf-accent: #14b8b8;
    --cf-accent-hover: #3ad3d3;
    --cf-on-accent: #06201f;
    --cf-shadow: rgba(0, 0, 0, 0.55);
  }
 
  body {
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    display: flex;
    justify-content: center;
    align-items: center;
    min-height: 100vh;
    margin: 0;
    background: var(--cf-page);
    color: var(--cf-text);
  }
 
  #currency-widget {
    width: 360px;
    padding: 30px;
    border-radius: 15px;
    background: var(--cf-surface);
    border: 1px solid var(--cf-border);
    box-shadow: 0 8px 20px var(--cf-shadow);
    text-align: center;
  }
 
  .widget-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 24px;
  }
 
  h2 {
    font-size: 1.4em;
    margin: 0;
    color: var(--cf-text);
  }
 
  #theme-toggle {
    padding: 6px 14px;
    border-radius: 999px;
    border: 1px solid var(--cf-border);
    background: transparent;
    color: var(--cf-muted);
    font-size: 0.8em;
    cursor: pointer;
  }
 
  #theme-toggle:hover { color: var(--cf-text); }
 
  label {
    font-size: 0.95em;
    color: var(--cf-muted);
    margin-bottom: 8px;
    display: block;
    text-align: left;
  }
 
  select, input[type="number"], input[type="text"] {
    width: 100%;
    box-sizing: border-box;
    padding: 12px;
    margin-bottom: 20px;
    border: 1px solid var(--cf-border);
    border-radius: 8px;
    font-size: 1em;
    background-color: var(--cf-field);
    color: var(--cf-text);
  }
 
  select:focus, input:focus {
    outline: 2px solid var(--cf-accent);
    outline-offset: 1px;
  }
 
  #result {
    font-size: 1.5em;
    font-weight: bold;
    color: var(--cf-text);
    margin-top: 20px;
  }
 
  #currency-widget button#convertButton {
    width: 100%;
    padding: 12px;
    background-color: var(--cf-accent);
    border: none;
    border-radius: 8px;
    color: var(--cf-on-accent);
    font-size: 1.1em;
    cursor: pointer;
    transition: background-color 0.3s ease;
  }
 
  #currency-widget button#convertButton:hover {
    background-color: var(--cf-accent-hover);
  }
</style>
</head>

Step 3: Adding HTML Structure for the Widget

Within the <body> section, create the structure of the currency exchange widget.

<body>
<div id="currency-widget">
  <div class="widget-head">
    <h2>Currency Exchange Rate</h2>
    <button type="button" id="theme-toggle" aria-pressed="false">Dark mode</button>
  </div>
 
  <label for="fromCurrency">From:</label>
  <select id="fromCurrency" class="searchable"></select>
 
  <label for="toCurrency">To:</label>
  <select id="toCurrency" class="searchable"></select>
 
  <label for="amount">Amount:</label>
  <input type="number" id="amount" value="1" min="0" step="any">
 
  <output id="result" aria-live="polite"></output>
  <button id="convertButton">Convert</button>
</div>

Step 4: Adding JavaScript for Functionality: JavaScript Exchange Rate Widget

This script makes one request to the Latest Rates endpoint on page load, fills both dropdowns from the response, and calculates every pair from that single payload.

That last point is the important one. The free Developer plan returns rates against a USD base and rejects the base parameter, so a widget that sends base=EUR fails on the plan most readers are actually using. Dividing two USD-quoted rates gives the same answer without a second request:

EUR to GBP = rates["GBP"] / rates["EUR"]

The side effect is a quota saving. The common tutorial pattern fires a request on every keystroke in the amount field, which can spend a month of free calls in a single session. This version spends one call per page load regardless of how much the visitor types.

<script>
document.addEventListener('DOMContentLoaded', function () {
  const API_KEY = 'YOUR_API_KEY';
 
  const fromCurrency = document.getElementById('fromCurrency');
  const toCurrency = document.getElementById('toCurrency');
  const amount = document.getElementById('amount');
  const result = document.getElementById('result');
  const convertButton = document.getElementById('convertButton');
 
  let usdRates = null; // every rate quoted against a USD base
 
  fetch(`https://api.currencyfreaks.com/v2.0/rates/latest?apikey=${API_KEY}`)
    .then(response => {
      if (!response.ok) throw new Error(`CurrencyFreaks responded ${response.status}`);
      return response.json();
    })
    .then(data => {
      usdRates = data.rates;
 
      // USD is the base currency, so the API omits it from `rates` - add it back for the dropdowns.
      Object.keys(usdRates).concat('USD').sort().forEach(code => {
        fromCurrency.add(new Option(code, code));
        toCurrency.add(new Option(code, code));
      });
 
      fromCurrency.value = 'USD';
      toCurrency.value = 'EUR';
 
      fromCurrency.addEventListener('change', updateResult);
      toCurrency.addEventListener('change', updateResult);
      amount.addEventListener('input', updateResult);
      convertButton.addEventListener('click', updateResult);
 
      makeDropdownSearchable(fromCurrency);
      makeDropdownSearchable(toCurrency);
 
      updateResult();
    })
    .catch(error => {
      console.error('Rate load failed:', error);
      result.textContent = 'Could not load exchange rates. Please try again.';
    });
 
  function rateOf(code) {
    if (code === 'USD') return 1;
    const value = parseFloat(usdRates[code]);
    return Number.isFinite(value) ? value : null;
  }
 
  function updateResult() {
    if (!usdRates) return;
 
    const from = fromCurrency.value;
    const to = toCurrency.value;
    const amt = parseFloat(amount.value);
    const fromRate = rateOf(from);
    const toRate = rateOf(to);
 
    if (!Number.isFinite(amt) || fromRate === null || toRate === null) {
      result.textContent = '';
      return;
    }
 
    // USD is the pivot currency, so FROM to TO is toRate divided by fromRate.
    const converted = (amt * (toRate / fromRate)).toFixed(4);
    result.textContent = `${amt} ${from} = ${converted} ${to}`;
  }
 
  function makeDropdownSearchable(dropdown) {
    const searchInput = document.createElement('input');
    searchInput.type = 'text';
    searchInput.setAttribute('placeholder', 'Search currencies');
    dropdown.parentNode.insertBefore(searchInput, dropdown);
 
    searchInput.addEventListener('input', function () {
      const filter = searchInput.value.toLowerCase();
      for (const option of dropdown.options) {
        option.hidden = !option.textContent.toLowerCase().includes(filter);
      }
    });
  }
});
</script>

Returns: a populated pair of currency dropdowns and a converted amount that updates locally, using one API call per page load.

Every parameter the Latest Rates endpoint accepts, including symbols and the paid base option, is listed in the CurrencyFreaks API documentation.

jQuery version

Already running jQuery on the page? The same cross-rate logic in eleven lines. Use this instead of the vanilla script above, never alongside it.

<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script>
$(function () {
  const API_KEY = 'YOUR_API_KEY';
  let usdRates = null;
 
  $.get('https://api.currencyfreaks.com/v2.0/rates/latest', { apikey: API_KEY })
    .done(function (data) {
      usdRates = data.rates;
      // USD is the base currency, so the API omits it from `rates` - add it back for the dropdowns.
      $.each(Object.keys(usdRates).concat('USD').sort(), function (i, code) {
        $('#fromCurrency, #toCurrency').append($('<option>').val(code).text(code));
      });
      $('#fromCurrency').val('USD');
      $('#toCurrency').val('EUR');
      $('#amount, #fromCurrency, #toCurrency').on('input change', convert);
      convert();
    })
    .fail(function () {
      $('#result').text('Could not load exchange rates. Please try again.');
    });
 
  function rateOf(code) {
    return code === 'USD' ? 1 : parseFloat(usdRates[code]);
  }
 
  function convert() {
    if (!usdRates) return;
    const from = $('#fromCurrency').val();
    const to = $('#toCurrency').val();
    const amt = parseFloat($('#amount').val());
    if (!Number.isFinite(amt)) return $('#result').text('');
    const converted = (amt * (rateOf(to) / rateOf(from))).toFixed(4);
    $('#result').text(`${amt} ${from} = ${converted} ${to}`);
  }
});
</script>
</body>
</html>

Returns: the same converted amount as the vanilla build, using jQuery event binding.

Add Dark Mode to the Currency Converter Widget

Dark mode on a currency converter widget is a three-line problem once the palette lives in CSS custom properties. The pattern in Step 2 covers all three states a visitor can be in: no preference set, an OS-level dark preference, and a manual choice made on your site.

Add this script anywhere after the widget markup to wire up the toggle button from Step 3. It stores the choice so the widget still matches on the next visit.

<script>
(function () {
  const root = document.documentElement;
  const toggle = document.getElementById('theme-toggle');
  const stored = localStorage.getItem('cf-theme');
 
  if (stored === 'dark' || stored === 'light') {
    root.setAttribute('data-cf-theme', stored);
  }
  paint();
 
  toggle.addEventListener('click', function () {
    const next = active() === 'dark' ? 'light' : 'dark';
    root.setAttribute('data-cf-theme', next);
    localStorage.setItem('cf-theme', next);
    paint();
  });
 
  function active() {
    const pinned = root.getAttribute('data-cf-theme');
    if (pinned === 'dark' || pinned === 'light') return pinned;
    return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
  }
 
  function paint() {
    const dark = active() === 'dark';
    toggle.textContent = dark ? 'Light mode' : 'Dark mode';
    toggle.setAttribute('aria-pressed', String(dark));
  }
})();
</script>

Returns: a working theme toggle that overrides the system setting and survives a page reload.

Four approaches show up in the wild. The third is the one worth using.

ApproachFollows OS settingRespects a manual toggleExtra requestsBest for
prefers-color-scheme onlyYesNo0Sites with no theme switcher
data-cf-theme attribute onlyNoYes0Sites that already ship a toggle
Attribute overriding the media queryYesYes0Default recommendation
Two stylesheets swapped by JavaScriptYesYes1 extra CSS fileLegacy builds without custom properties

Two details catch people out. Setting color-scheme: light dark on :root is what makes native form controls, scrollbars and the number input spinners repaint, so without it the dropdowns stay stubbornly white inside an otherwise dark card. And the guard :root:not([data-cf-theme="light"]) on the media query block is what lets a visitor force light mode on a machine set to dark. Drop the guard and the toggle only works in one direction.

Output

Select your desired currency symbols to get current or historical exchange rates or currency rates through requests volumeConvert currencies without any hidden feesFree server output to exchange world currencies through currencyfreaks api requests

Learn to create currency widget using Vue JS.

Currency Converter API for JavaScript (Free Options)

Four things decide whether a rate API is usable from browser JavaScript, and only one of them is price.

CORS matters most. The widget above calls the API directly from the page, so the endpoint has to answer a cross-origin request from an arbitrary domain. Any provider that requires a server-side proxy turns a two-line embed into a backend project.

Base currency handling comes second. Most free tiers, CurrencyFreaks included, quote everything against USD and gate the base parameter behind a paid plan. That is not a blocker, it just means you divide two rates instead of asking the API to do it. Check this before you write the fetch call, not after.

Then payload size and quota. A full Latest Rates response covers every supported currency in one call, which is what fills the dropdowns. Once the visitor is converting a known pair, symbols=EUR,GBP trims the response to what you need.

What to checkWhy it matters for a browser widgetFree Developer plan
CORS on the endpointDirect fetch from the page, no proxySupported
Base currency parameterDecides cross-rate math versus API-side conversionUSD base only, paid plans unlock others
Monthly call quotaOne call per page load adds up on a busy page1,000 calls per month
Update frequencySets how stale a displayed rate can beEvery 24 hours
Historical endpointNeeded for trend charts, not for conversionPaid plans

Plan limits and prices change. Confirm the current tiers on the CurrencyFreaks pricing page before you commit an architecture to them.

How to Fetch Exchange Rates in JavaScript

Chained promises work, but async/await reads better and makes the caching layer obvious. The version below fetches once, keeps the payload in sessionStorage for an hour, and returns the same object shape either way.

const RATE_TTL_MS = 60 * 60 * 1000; // free plan refreshes every 24 hours anyway
 
async function getRates() {
  const apiKey = 'YOUR_API_KEY';
  const cached = JSON.parse(sessionStorage.getItem('cf-rates') || 'null');
 
  if (cached && Date.now() - cached.at < RATE_TTL_MS) {
    return cached.rates;
  }
 
  const url = `https://api.currencyfreaks.com/v2.0/rates/latest?apikey=${apiKey}`;
  const response = await fetch(url);
  if (!response.ok) throw new Error(`CurrencyFreaks responded ${response.status}`);
 
  const { rates } = await response.json();
  sessionStorage.setItem('cf-rates', JSON.stringify({ at: Date.now(), rates }));
  return rates;
}
 
// Convert any pair from the USD-quoted payload.
function convert(rates, amount, from, to) {
  const rate = code => (code === 'USD' ? 1 : parseFloat(rates[code]));
  return amount * (rate(to) / rate(from));
}

Returns: a USD-quoted rates object from cache when it is fresh, from the API when it is not.

Note that getRates() takes no base argument. Passing one on the free Developer plan returns an error rather than re-based rates, which is why the conversion happens in convert() instead.

Helpful Resource: How to Get Live Currency Rates in Google Sheets Using a Currency Free API

Why Build This With the CurrencyFreaks API

The widget works because a single /rates/latest call returns every supported currency at once. That one response populates both dropdowns in Step 4 and supplies the numbers for every pair the visitor picks afterwards, which is why updateResult() never touches the network.

Two parameters shape what that response looks like. symbols=EUR,GBP trims it to named currencies, useful when a page only ever quotes two or three pairs. base re-quotes everything against a currency other than USD, and it is available on paid subscription plans only, so the free build divides rates locally instead.

One behaviour worth knowing before you ship: rate values arrive as strings, not numbers. data.rates.EUR is "0.9142", not 0.9142. Skip the parseFloat and JavaScript will happily concatenate instead of multiply, and the widget will show something like 100USD = 1000.9142 EUR with no error thrown anywhere.

Where This Widget Pattern Fits

The same fromCurrency/toCurrency/amount structure built above drops into a few common cases with minor changes:

  • An e-commerce product page: swap the #result div for a price display, and re-run updateResult() whenever the shopper changes their currency in the header.
  • A travel or booking form: seed fromCurrency with the traveler's home currency and toCurrency with the destination currency by default.
  • A finance dashboard: call getRates(base) from the async/await example on a timer instead of on click, so the displayed rates refresh without user interaction.
  • A SaaS billing page: use the base parameter to re-quote a fixed-price plan in the customer's local currency at checkout.

Each of these reuses the same single API call from Step 4. Only the trigger and the display target change.

Vanilla JS vs React Currency Widget

The script in this guide is roughly 60 lines and ships zero dependencies. A React equivalent needs a bundler, a build step, and a hydration story before it renders a single dropdown. For a widget dropped into a marketing page, a WordPress post, or a Webflow embed, that overhead buys you nothing.

React earns its cost when the converted value has to stay in sync with other state. A checkout that re-prices line items, a dashboard where six panels share one rate payload, a form that validates against a converted total: those are state problems, and a useState plus a shared context handles them better than passing DOM nodes around.

Theme handling is the one place the vanilla build has a genuine edge. CSS custom properties and prefers-color-scheme need no JavaScript at all, while most React theme setups reach for a provider and a hook to do the same job.

FactorVanilla JavaScriptReact
SetupTwo files, no buildBundler, build step, dependencies
Bundle costUnder 4KB45KB minimum before your code
Shared rate stateManualContext or store
Dark modeCSS onlyProvider plus hook, usually
Best fitEmbeds, blogs, static sitesDashboards, checkouts, SPAs

Building inside an existing React app? The React currency converter API tutorial covers the hook version, and there is a Vue currency converter guide for the same widget in Vue.

Helpful Resource: Integrating CurrencyFreaks Free currency converter API with React JS: A Tutorial

Conclusion

You now have a currency converter widget that respects the visitor's theme, embeds in two lines, and runs correctly on a free API plan because it derives cross rates instead of asking the API to re-base them.

Three things to check before you ship it. Confirm your key is in YOUR_API_KEY and not committed to a public repository. Decide how stale a rate can be for your use case, since the free Developer plan refreshes every 24 hours while paid plans go down to 60 seconds. And test the toggle on a machine set to dark at the OS level, which is where the :not([data-cf-theme="light"]) guard proves it is doing something.

FAQs

How Can I Improve Performance and Save API Credits in JavaScript?

You can significantly improve performance by caching API responses in sessionStorage. Since currency rates don’t typically change every second, storing the data locally for a set period (like 1 hour) prevents unnecessary network requests and saves your API quota.

What Currency Source Should I Use for This Widget?

CurrencyFreaks aggregates rates from forex exchanges, cryptocurrency exchanges and national banks, including the European Central Bank, and the /rates/latest endpoint is what the widget above reads. How often those rates refresh depends on your plan: every 24 hours on the free Developer plan, hourly on Starter, and every 60 seconds on Professional. Match the refresh rate to your use case before you display a value as current.

How Much Does the CurrencyFreaks Currency Converter API Cost to Create a Widget?

CurrencyFreaks currency converter API offers multiple pricing plans, including a free tier. For the most accurate and up-to-date pricing details, visit the official CurrencyFreaks pricing page.

What Support Is Available When Building a Widget on the CurrencyFreaks API?

Documentation covers every endpoint used here, including /rates/latest, with request and response examples in eleven languages. Support level scales with plan: limited support on the free Developer tier, basic support on Starter, and premium support on Growth and above. Check the current tier details before relying on a specific response time.

Sign Up for free at CurrencyFreaks currency converter API and get 1000 free API calls today!