Currency Converter

Introduction

Screenshots of the currency converter app
Screenshots of the currency converter app

Millions in India use keypad mobiles like the Itel Super Guru 4G to run their businesses. Apps like GSPay, acquired by PhonePe, offer Unified Payments Interface (UPI) on feature phones, so anyone can instantly send and receive payments. However, merchants often rely on outdated estimates when converting foreign currency into the Indian rupee (₹).

In this article, you will design and build a currency conversion app for Cloud Phone. You will progressively enhance the app by localizing content and layouts, adding currency search, and hosting the widget for free on GitHub Pages.

Why Cloud Phone? Cloud Phone uses a Chromium-based remote browser, like Opera Mini, capable of running Progressive Web App (PWA). Unlike a unit converter with static weights & measures, foreign exchange (Forex) rates change frequently. Our app will fetch daily exchange rates and display locale-aware conversions.

Design

Figma - currency converter app
Figma - currency converter app

Create designs in a tool like Figma before writing any code. Most Cloud Phone devices have QVGA (240x320) or QQVGA (128x160) displays. Widgets operate fullscreen without a status bar or software navigation buttons. Design the user interface (UI) with these constraints in mind.

In this design, use a few Tabler icons, Freak Flags, and Cloud Phone’s default font, Roboto. To maximize comprehension among a diverse user base, include:

  • Currency symbols ($ € ₹)
  • Currency codes (USD, EUR, INR)
  • Flags (🇺🇸 🇪🇺 🇮🇳)

Localization. Designs include both right-to-left (RTL) languages like English and Hindi, as well as left-to-right (LTR) languages like Urdu and Arabic. Designs also localize currencies and quantities according to regional numbering systems. Finally, rather than cover every country, focus on geographies with a large number of Cloud Phone users primarily in India, Bangladesh, Pakistam, the Philippines, and South Africa.

Inspiration. It is easy to find inspiration from apps on smartphones, tablets, and smart watches. For example, the currency converter app built by the British Post Office for the Apple Watch, or lists of top converter apps on Android and iOS.

Technical Overview

This walkthrough uses TypeScript and sources currency rates from exchange-api, a free currency exchange rate API with 200+ currencies. Because Cloud Phone uses a recent Chromium version, backwards compatibility and transpilation is not necessary. Instead, the interface is built using Vanilla JS, without the aid of frameworks like React.

Setup

To begin, create a package.json file.

npm init

Follow the prompts and type “yes” on the final confirmation step, “Is this OK? (yes).” Next, install TypeScript as a development dependency.

npm install --save-dev --save-exact typescript

Next install rsbuild to bundle all code into a single, minified JavaScript file.

npm install --save-dev --save-exact rsbuild

Optional: use Prettier to consistently format TypeScript and JavaScript code.

npm install --save-dev --save-exact prettier

That’s it! There are no special libraries or custom build configurations for Cloud Phone.

RSBuild Configuration

Here is a simplified version of the rsbuild configuration used to bundle the currency converter app.

rsbuild.config.ts
import { pluginHtmlMinifierTerser } from "rsbuild-plugin-html-minifier-terser";
import { defineConfig } from "@rsbuild/core";

const BASE_PATH = process.env.BASE_PATH || "/";
const NODE_ENV = process.env.NODE_ENV;

export default defineConfig({
  output: {
    assetPrefix: BASE_PATH,
  },
  performance: {
    removeConsole: (NODE_ENV !== "development"),
  },
  plugins: [
    pluginHtmlMinifierTerser({
      collapseBooleanAttributes: true,
      collapseWhitespace: true,
    }),
  ],
  server: {
    publicDir: false,
  },
  html: {
    template: "index.html",
    favicon: "./assets/img/favicon.ico",
  },
});

The assetPrefix: BASE_PATH output is used for GitHub Pages because it will be deployed under the subfolder /cloudphone-currency/. This subfolder comes from the repository name.

The removeConsole configuration removes calls to functions like console.log used in debugging.

The pluginHtmlMinifierTerser plugin minifies the resulting HTML.

The publicDir setting disables copying the Public Folder that typically contains static assets. Instead, images are stored in the /assets folder and processed by rsbuild.

Currency Data

The exchange-api provides exchange rates for 340+ currencies. However, many of these are irrelevant to first-time internet users. These include niche crypocurrencies, extranational currencies like IMF Special Drawing Rights, or obsolete currencies no longer in circulation (i.e. Dutch Guilder). Using Google Gemini, the list was filtered and expanded to include currency symbols, flag emojis, and denominations to round to an appropriate number of decimals.

For example, the key-value pair "vnd": "Vietnamese Dong" was replaced with the following object:

{
  "currencyCode": "vnd",
  "englishCurrencyName": "Vietnamese Dong",
  "localCurrencyName": "Đồng Việt Nam",
  "currencySymbol": "₫",
  "countryRegionFlagEmoji": "🇻🇳",
  "languageCode": "vi-VN",
  "smallestCommonDenomination": 1000
}

Exchange rates are fetched from jsdelivr.net, with a fallback to Cloudflare Pages for improved reliability. Responses include the “as of” date, a key with the requested base currency, and an object containing key-value pairs with exchange rates for a 1.0 quantity of the base currency.

inr.json
{
  "date": "2025-07-13",
  "inr": {
    "usd": 0.011653038,
    "eur": 0.0099662681,
    "gbp": 0.0086112051,
    // ...
  }
}

TypeScript types are created for type safety and ergonomics, including the use of union types and indexed access types.

currency.d.ts
type CurrencyCode = 'usd' | 'eur' | 'inr' | 'gbp' // ...

export type ExchangeRateResponse = {
    date: ExchangeRateDate
} & {
    [base in CurrencyCode]?: {
        [target in Exclude<CurrencyCode, base>]?: number
    }
}

Calculations

exchange-api provides conversion rates against a base currency with the value of 1.00. In the example above, ₹1 ≈ $0.011653038 (just over 1¢). The same data can calculate conversion rates for any currency pair. Final results are rounded.

INR → USD: ₹50 = 50 × 0.011653038 = $0.58 USD
USD → INR: $50 = 50 / 0.011653038 = ₹4,291 INR
EUR → GBP: €50 = (50 / 0.0099662681) × 0.0086112051 = £43.20 GBP

In the last example, €50 EUR is first converted into INR, and then from INR into GBP.

Convert Currencies
export function exchange(
  quantity: number,
  fromCurrency: CurrencyCode,
  toCurrency: CurrencyCode,
  exchangeRates: INRExchangeRateResponse,
): number {
  const inrRates = exchangeRates.inr;
  const from = fromCurrency as keyof typeof inrRates;
  const to = toCurrency as keyof typeof inrRates;

  // Use conversion as-is when starting currency is INR
  if (fromCurrency === "inr") {
    return quantity * inrRates[to];
  }

  // Invert conversions when ending currency is INR
  if (toCurrency === "inr") {
    return quantity / inrRates[from];
  }

  // Two-step conversions when currency pairs do not include INR
  return (quantity / inrRates[from]) * inrRates[to];
}

Fetch exchange rates for one currency, and perform two-step calculations, to avoid fetching rates again when the base currency changes.

Currency Formatting

Use the Intl.NumberFormat API to format number quantities as locale-sensitive currencies. This includes displaying the appropriate symbol, separators, and rounding to an appropriate number of decimals.

Although 50 paise coins (½ Rupee) are still legal tender, in regular life prices are rounded to whole Rupee quantities. Similarly in Vietnam, although notes as low as 100 VND are legal tender, the smallest common Vietnamese Dong banknote in circulation is 1,000 VND (coins are largely commemorative).

👎 Don’t

  • ₫125,775.00
  • ₹4,289.17
  • $10.107391046
  • ¥18,749.21

👍 Do

  • ₫126,000
  • ₹4,289
  • $10.11
  • ¥18,749
Format a number as currency
export function formatCurrency(quantity: number, currency: Currency): string {
  const formatter = new Intl.NumberFormat(navigator.language, {
    style: "currency",
    currencyDisplay: "narrowSymbol",
    currencySign: "standard",
    currency: currency.currencyCode.toUpperCase(),
    roundingIncrement: getRoundingIncrement(currency),
    maximumFractionDigits: getFractionDigits(currency),
  });

  return formatter.format(quantity);
}

Rounding lowers precision, but also reduces cognitive load. For example, ₹50 ≈ $0.5826519 USD is more precise, but $0.58 is more relatable because coins do not exist to pay amounts below 1¢. To minimize compounding accuracy loss, rounding should only done as the last step after calculation.

Rounding leads to somewhat quirky quantization: distinct inputs collapse to the same output. For example, both $0.04 and $0.05 round to the same value of ₹4. This is an acceptable tradeoff to improve comprehension.

Prototyping

Chrome DevTools > Devices
Chrome DevTools > Devices

Use Google Chrome for quick prototyping. Under Settings > Devices, click “Add custom device” and configure two device profiles with Cloud Phone user agents, resolutions of QVGA (240x320) and QQVGA (128x160), and set the type to “Mobile (no touch).”

Chrome DevTools > Devices > Edit Device
Chrome DevTools > Devices > Edit Device

Iterations

Early prototypes of a Cloud Phone currency converter
Early Cloud Phone currency converter prototypes

Prototyping allows you to test and refine your app before investing significant time or resources. This iterative process helps identify design flaws and risks before they become too expensive to fix.

Although the above prototype does not look nice, it can be helpful to start with basic functionality like entering and converting currencies before creating complex styles and animations. It can also reveal which features should be prioritized. Because Cloud Phone screens are so small, it is often beneficial to scale back the features offered so user’s are not overwhelmed with too many settings and configurations.

Process

Start with boilerplate HTML, add necessary elements, wire up JavaScript events, and begin prototyping. Break the app into several iterative milestones.

Milestones

  1. Fetch currencies and calculate pre-defined, static conversions
  2. Accept user input and calculate conversions between a fixed currency pair
  3. Allow user to reverse the fixed currency pair
  4. Allow user to change currencies
  5. Remember the user’s previous currency selection
  6. Add an About page
  7. Add a currency search page
  8. Add CSS styles to match design

Initial HTML

Start with HTML similar to the document below.

index.html
<!doctype html>
<html lang="en" class="no-js">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="preconnect" href="https://cdn.jsdelivr.net" />
    <title>Currency Converter</title>
  </head>
  <body>
    <div id="splash">
      <img src="/img/icon.png" alt="Icon" loading="eager" />
    </div>
    <div id="app">
      <header id="cp-header"></header>
      <section class="currency-selection">
        <label for="currency1" id="currency1-label"></label>
        <div id="currency-container1"></div>
      </section>
      <div id="separator"></div>
      <div id="reverse" tabindex="0">
      </div>
      <section class="currency-selection">
        <label for="currency2" id="currency2-label"></label>
        <div id="currency-container2"></div>
      </section>
      <!-- Soft Keys -->
      <footer id="cp-softkeys">
        <!-- Left (LSK) -->
        <div>
        </div>
        <!-- Middle -->
        <div>
        </div>
        <!-- Right (RSK) -->
        <div>
        </div>
      </footer>
    </div>
    <!-- Select a Currency -->
    <dialog id="choose-currency">
      <ol id="currency-list"></ol>
      <template id="currency-list-item">
        <li>
          <div class="currency-item">
            <div class="currency-flag">
              <i class="fflag ff-round ff-md"></i>
            </div>
            <div class="currency-symbol"></div>
            <div>
              <div class="currency-name">
                <span></span>
              </div>
              <div class="currency-code"></div>
            </div>
          </div>
        </li>
      </template>
    </dialog>
    <!-- About Page -->
    <dialog tabindex="0" id="about">
      <div>
        <div class="icon"></div>
        <h2>Currency Converter</h2>
        <p>
          Rates as of <u id="as-of-date">August 1st, 2025</u> A free currency converter to help with exchange rates
        </p>
      </div>
    </dialog>
    <!-- Search for Currency -->
    <dialog id="search">
      <header id="search-header">
        <input type="text" id="search-input">
      </header>
      <div id="search-list-container">
        <ol id="search-list"></ol>
      </div>
    </dialog>
  </body>
</html>

Note that the meta viewport element scales the viewport to match the device width. The value width=device-width, initial-scale=1.0 is commonly used by most Cloud Phone apps.

Element

Unfortunately there is no <input type="currency" /> element, but there are numerous libraries and components for frameworks of all kinds. There are fewer libraries available for Vanilla JS, and fewer still that encapsulate this login into a Web Component. After some prompting to ChatGPT (see conversation), the <currency-input> element was created.

The <currency-input> element has several notable features:

  • Prefixes currency symbols (i.e. 100 becomes $100)
  • Localizes decimal dividers (i.e. $1,000.50 vs €1.000,50)
  • Rounds to a denomination-appropriate precision
  • Handle KeyEvents to update the caret position

x-puffin-entersfullscreen

Importantly, it also uses the custom x-puffin-entersfullscreen attribute to tell Cloud Phone not to open the default fullscreen Input Method Editor (IME) when the user types in the <input> element. This allows for much faster input, but loses access to predictive text (which is not necessary for numeric input).

The Web Component is finally registered using the global customElements object.

customElements.define("currency-input", CurrencyInput);

Reversing Currencies

A traveler might first convert their home currency (e.g., USD) to Rupees to see what their money is worth. But when shopping, they may see a price in Rupees and need to know how much that costs in their home currency. The reverse button instantly flips the conversion without needing to re-enter currencies.

The reverse function looks like this:

export function reverseCurrencies() {
  // Flip values
  [currency1, currency2] = [currency2, currency1];
  [quantity1, quantity2] = [quantity2, quantity1];

  // Save flipped values
  storeCurrency();

  // Update UI labels
  updateLabel(currencyLabel1, currency1);
  updateLabel(currencyLabel2, currency2);

  // Update input (if user has typed value)
  const hasInput = currencyInput1.value || currencyInput2.value;
  if (hasInput) {
    currencyInput1.value = quantity1;
    currencyInput2.value = quantity2;
  }

  currencyInput1.currency = currency1;
  currencyInput2.currency = currency2;

  // Reverse header
  updateHomeHeader();

  // Dispatch custom event
  window.dispatchEvent(
    new CustomEvent<CurrenciesReversedEvent>(CURRENCIES_REVERSED, {
      detail: { currencies: [currency1, currency2] },
    }),
  );
}

It swaps the selected currencies and values, updates the UI, and dispatches a CustomEvent in case other parts of the app need to respond to changes in currency pairs.

Selecting Currencies

Next, allow the user to select a different option from the list of available currencies. In the HTML above, the <template> element is used to define the structure for a currency list item.

<template id="currency-list-item">
  <li>
    <div class="currency-item">
      <div class="currency-flag">
        <i class="fflag ff-round ff-md"></i>
      </div>
      <div class="currency-symbol"></div>
      <div>
        <div class="currency-name">
          <span></span>
        </div>
        <div class="currency-code"></div>
      </div>
    </div>
  </li>
</template>

The <template> element serves as a mechanism to hold HTML fragments that are later rendered via JavaScript. The array of currencies is then reduced to a DocumentFragment that contains a <li> element for each currency, with the appropriate classes and content (names, symbols, and currency codes).

const template = document.getElementById("currency-list-item");

export function createListItem(currency: Currency) {
  const countryCode = getCountryCode(currency);

  // Clone the list item template
  const clone = template.content.cloneNode(true) as DocumentFragment;
  const li = $("li", clone) as HTMLLIElement;
  li.tabIndex = 0;

  // Set attributes
  const item = $(".currency-item", clone) as HTMLDivElement;
  item.dataset.code = currency.currencyCode;
  item.lang = currency.languageCode;

  // Update classes and content
  $(".fflag", clone)?.classList.add(`fflag-${countryCode}`);
  $(".currency-symbol", clone).textContent = currency.currencySymbol.substring(0, 1);
  $(".currency-name span", clone).textContent = currency.localCurrencyName;
  $(".currency-code", clone).textContent = currency.currencyCode;

  return clone;
}

export function populateList() {
  const fragment = CURRENCIES.map(createListItem).reduce(
    (frag, item) => (frag.appendChild(item), frag),
    document.createDocumentFragment(),
  );

  list.append(fragment);
}
DocumentFragment PerformanceIt is often said that DocumentFragment is beneficial for performance reasons since many changes can be made to a collection of elements before appending them all to the Document Object Model (DOM). In practice, any performance benefit is negligible. Instead, a DocumentFragment is used here as an empty container for <li> elements to be appends to the ordered currency list, <ol id="currency-list"></ol>.

Remember Currency Selection

Cloud Phone offers the same Data Storage technologies as Google Chrome, including localStorage and IndexedDB. Use localStorage for simple key-value storage to remember user-selected currency pairs.

// Load saved currencies (if set)
let currency1: CurrencyCode =
  (localStorage.getItem("currency1") as CurrencyCode) ?? "inr";
let currency2: CurrencyCode =
  (localStorage.getItem("currency2") as CurrencyCode) ?? "usd";

// Save selected currency
function storeCurrency() {
  localStorage.setItem("currency1", currency1);
  localStorage.setItem("currency2", currency2);
}

Searching Currencies

Finally, with 150+ currencies to choose from, it can be time consuming selecting the one you want. To make currency selection easier, add search to quickly filter the list. Although there are plenty of search libraries available, with a search space of ~150 currencies, a library is unnecessary. Since Cloud Phone servers are comparable to high-end desktops, the performance penalty of substring searching is negligible.

To search, match either the currency code (i.e. USD) or the name (i.e. United States Dollar), then sort results based on relevance. For example, consider the query US: although Australia contains the substring US, it is more relevant to show USD above AUD. However, not all users will be familiar with currency codes so it is also important to allow searching the currency name either in English or the language most relevant in a given country.

function searchCurrencies(searchText: string) {
  return (a: Currency) => {
      // Prefix match
      const currencyCodeA = a.currencyCode.toLocaleLowerCase();
      if (currencyCodeA.startsWith(searchText)) return true;

      // Simple substring search
      return `${a.englishCurrencyName} ${a.localCurrencyName}`
        .toLocaleLowerCase()
        .includes(searchText);
    };
}

function sortCurrencies(searchText: string) {
  return (a: Currency, b: Currency) => {
      const currencyCodeA = a.currencyCode.toLocaleLowerCase();
      const currencyCodeB = b.currencyCode.toLocaleLowerCase();

      // Exact currency code match
      if (currencyCodeA === searchText) return -1;
      if (currencyCodeB === searchText) return 1;

      // Prefix currency code match
      if (currencyCodeA.startsWith(searchText)) return -1;
      if (currencyCodeB.startsWith(searchText)) return 1;

      return 0;
    };
}

function populateList() {
  const searchText = input.value.toLocaleLowerCase();

  // Clear previous results
  list.innerHTML = "";

  // Create a document fragment with sorted currency search results
  const searchResultsElement = CURRENCIES
    .filter(searchCurrencies(searchText))
    .sort(sortCurrencies(searchText))
    .map(createListItem)
    .reduce((frag, item) => (frag.appendChild(item), frag), document.createDocumentFragment());

  list.append(searchResultsElement);
}

Adding Styles

Using rsbuild, it is easy to keep CSS styles and JavaScript separate. Styles are associated using an import statement like import "./header.css";.

header.ts
import "./header.css";

const header = document.getElementById("cp-header")
  as HTMLHeadElement;

let currentText = "";

export function setHeaderText(text: string) {
  currentText = header.innerText = text;
}

Styles are then adapted for different screen resolutions using media queries.

header.css
header {
  text-align: center;
  font-weight: bold;
  width: 100vw;
  white-space: nowrap;
  overflow: hidden;
  background: var(--bg-accent);
  color: white;
}

/* QVGA (or bigger) */
@media only screen and (min-width: 129px) {
  header {
    height: 36px;
    line-height: 36px;
    font-size: 18pt;
  }
}

/* QQVGA */
@media only screen and (max-width: 128px) {
  header {
    height: 20px;
    line-height: 20px;
    font-size: 9pt;
  }
}

Wrapping Up

This tutorial does not cover every line of code, but the entire codebase is available on GitHub under the permissive Apache 2.0 license. If you find a bug, please create a GitHub Issue. If you’d like to publish your own app for Cloud Phone, get in touch!

Contact Us

Publish your own app on Cloud Phone

Get Support