Currency Converter
Introduction

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

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.
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.
{
"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.
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.
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
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

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).”

Iterations

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
- Fetch currencies and calculate pre-defined, static conversions
- Accept user input and calculate conversions between a fixed currency pair
- Allow user to reverse the fixed currency pair
- Allow user to change currencies
- Remember the user’s previous currency selection
- Add an About page
- Add a currency search page
- Add CSS styles to match design
Initial HTML
Start with HTML similar to the document below.
<!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 Performance
It is often said thatDocumentFragment
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";
.
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 {
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!