Skip to content

Instantly share code, notes, and snippets.

@ThomasR
Last active June 9, 2025 16:35
Show Gist options
  • Save ThomasR/065c082fdfe92ea323a76149627e8fc9 to your computer and use it in GitHub Desktop.
Save ThomasR/065c082fdfe92ea323a76149627e8fc9 to your computer and use it in GitHub Desktop.
A no-bullshit dependency-free currency converter for your browser. See it live at https://thomas-rosenau.de/currencyConversion.html
/*
* Copyright 2025 Thomas Rosenau
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
*, *::before, *::after {
box-sizing: border-box;
}
html {
font-family: "Twemoji Country Flags", "Source Sans Pro", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
font-size: 16px;
line-height: 1.25;
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-moz-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body {
margin: 0;
padding: 0;
&.loading main {
display: none;
}
&.loading::before {
display: block;
width: 2rem;
height: 2rem;
margin: auto;
content: "";
animation: rotate 2s linear infinite;
border: 4px solid #c0c0c0;
border-right-color: transparent;
border-left-color: transparent;
border-radius: 1rem;
}
}
@media screen {
body {
min-height: 100vh;
padding: 1rem;
}
}
h1 {
font-size: 2rem;
}
h2 {
font-size: 1.5rem;
}
h3 {
font-size: 1.2rem;
}
h4 {
font-size: 1.1rem;
}
h5 {
font-size: 1.1rem;
}
h6 {
font-size: .9rem;
}
h1, h2, h3, h4, h5, h6 {
line-height: 1.1;
margin: 0.67em 0;
text-wrap: balance;
}
pre, code, kbd, samp {
font-size: inherit;
}
small {
font-size: 80%;
}
details {
display: block;
}
summary {
display: list-item;
}
table {
border-spacing: 0;
border-collapse: collapse;
--table-border-color: hsla(330, 4%, 78%, 0.3);
--table-stripe-color: hsla(330, 4%, 78%, 0.15);
}
th, td {
padding: .25rem .5rem;
text-align: left;
border-right: 1px solid var(--table-border-color);
&:last-of-type {
border-right: none;
}
}
thead {
border-bottom: 1px solid var(--table-border-color);
}
tfoot {
border-top: 1px solid var(--table-border-color);
}
thead {
font-size: 120%;
}
thead th,
tr:nth-child(even) td,
table:has(tbody tr:nth-child(odd):last-of-type) tfoot th {
background-color: var(--table-stripe-color);
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>Currency Conversion</title>
<link rel="icon"
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>💱</text></svg>">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<link rel="stylesheet" href="base.css"/>
<link rel="stylesheet" href="form.css"/>
<style>
:root {
--form-theme-color: hsl(180, 100%, 30%);
}
html {
font-size: max(1.7vh, 16px);
}
main {
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 2rem;
}
table {
margin: auto;
--table-stripe-color: var(--form-background-color);
}
thead {
font-size: 120%;
}
tbody tr {
font-family: "JetBrains Mono", monospace;
}
th, td {
padding: 0 .25em 0 .75em;
text-align: right;
border: none;
}
th > span {
display: inline-block;
text-align: center;
}
th .flag {
display: block;
margin: 0;
}
:is(td, th):nth-of-type(even):not(:last-of-type) {
padding-right: 1rem;
border-right: 2px solid #909192;
}
tfoot th {
font-size: 80%;
font-weight: normal;
padding: .5em;
text-align: center;
background: transparent;
}
small {
font-size: 66%;
}
label small {
margin-top: .4em;
}
small.thousands {
margin-left: .15em;
opacity: .5;
&::before {
content: "000";
}
body:has([name="k-style"][value="000"]:checked).millions &::after {
content: "000";
margin-left: .15em;
}
body:has([name="k-style"][value=".000"]:checked) & {
font-size: unset;
margin: 0;
&::before {
content: ".000";
}
}
body:has([name="k-style"][value=".000"]:checked).millions & {
margin: 0;
&::before {
content: ".000.000";
}
}
body:has([name="k-style"][value="K"]:checked) &::before {
content: "K";
}
body:has([name="k-style"][value="K"]:checked).millions &::before {
content: "M";
}
}
form {
margin: auto;
}
form.k-style {
display: none;
flex-direction: row;
justify-content: space-around;
.show-k & {
display: flex;
}
}
label {
justify-content: space-between;
}
.suffix-input:has(input[name="foreignAmount"]), input[name="homeAmount"] {
width: unset;
min-width: 3em;
}
label:has([name="k"]) {
display: none;
.show-k & {
display: initial;
}
}
label:has([name="rate"]) output {
flex: 1;
}
label:has(.suffix-input) {
flex: 1;
}
.conversion {
display: grid;
font-size: 1.5rem;
align-items: center;
text-align: center;
row-gap: .5rem;
column-gap: .5rem;
grid-template-columns: max-content 1fr max-content;
& > * {
display: unset;
}
.equals {
grid-column: span 2;
}
label:has([name="foreignAmount"]) {
grid-column: span 2;
.show-k & {
grid-column: unset;
}
}
[name="homeAmount"] {
grid-column: span 2;
}
.currency {
justify-self: end;
}
}
[name="homeAmount"] {
text-align: right;
}
.flag {
margin-left: .3em;
}
@media screen {
html {
background: #f5f7f5;
}
table, form {
width: min(25rem, 100%);
}
table {
overflow: hidden;
border-radius: 0.5rem;
}
table + table {
display: none;
}
thead th {
padding-top: .5rem;
}
tfoot th {
line-height: 2;
}
:is(th, td):last-child:not([colspan]) {
padding-right: .75em;
}
table, form {
box-shadow: 0 0 5px #cccc;
}
.flag {
text-shadow: 0 0 2px #9999;
}
}
@media screen and (min-width: 700px) {
.currency-select,
.conversion {
width: calc(700px - 2rem);
}
.conversion {
grid-template-columns: max-content 1fr max-content 3rem 1fr max-content;
& .equals,
& [name="homeAmount"] {
grid-column: unset;
}
}
}
@media print {
@page {
size: landscape;
margin: 10mm;
}
html {
font-size: 13px;
}
main {
flex-direction: row;
height: calc(210mm - 2 * 15mm);
}
form {
display: none !important;
}
small.thousands {
opacity: 1;
color: #666;
}
table {
--table-stripe-color: hsl(210, 2%, 80%);
}
table, thead {
position: relative;
}
table::before, thead::before, table::after, thead::after {
--cutmark-size: .6cm;
display: block;
content: "";
position: absolute;
inset: 0;
width: var(--cutmark-size);
height: var(--cutmark-size);
border: 1px dashed red;
}
thead::before {
top: calc(-1 * var(--cutmark-size));
left: calc((100% - 6.8cm) / 2 - var(--cutmark-size) - 1px);
border-top: none;
border-left: none;
}
thead::after {
top: calc(-1 * var(--cutmark-size));
left: calc((100% + 6.8cm) / 2 - 1px);
border-top: none;
border-right: none;
}
table::before {
top: calc(10.05cm - 1px);
left: calc((100% - 6.8cm) / 2 - var(--cutmark-size) - 1px);
border-bottom: none;
border-left: none;
}
table::after {
top: calc(10.05cm - 1px);
left: calc((100% + 6.8cm) / 2 - 1px);
border-bottom: none;
border-right: none;
}
}
</style>
<script type="text/template" id="table-head">
<thead>
<tr>
<th><span><span class="flag">{{foreignFlag}}</span> <span class="currency">{{foreignCurrency}}</span></span></th>
<th><span><span class="flag">{{homeFlag}}</span> <span class="currency">{{homeCurrency}}</span></span></th>
<th><span><span class="flag">{{foreignFlag}}</span> <span class="currency">{{foreignCurrency}}</span></span></th>
<th><span><span class="flag">{{homeFlag}}</span> <span class="currency">{{homeCurrency}}</span></span></th>
</tr>
</thead>
</script>
<script type="text/template" id="table-row">
<tr>
<td>{{foreignAmount1}}</td>
<td>{{homeAmount1}}</td>
<td>{{foreignAmount2}}</td>
<td>{{homeAmount2}}</td>
</tr>
</script>
<script type="text/template" id="table-foot">
<tfoot>
<tr>
<th colspan="4">
{{updateDate}}
</th>
</tr>
</tfoot>
</script>
<script type="module" defer>
import { polyfillCountryFlagEmojis } from 'https://cdn.skypack.dev/country-flag-emoji-polyfill';
polyfillCountryFlagEmojis();
</script>
</head>
<body class="loading">
<main>
<form class="currency-select">
<label>Currency: <select name="currency"></select></label>
<label>Exchange rate: <input type="hidden" name="rate">
<output></output>
</label>
</form>
<form class="conversion">
<label><input type="checkbox" class="toggle-button" name="k" disabled>&#xD7;&#x202F;1.000</label>
<label><span class="suffix-input"><input type="text" name="foreignAmount"
value="0" onfocus="this.value=''"></span></label>
<span class="currency foreignCurrency"></span>
<span class="equals"> = </span>
<input type="text" name="homeAmount" value="1" onfocus="this.value=''"/><span class="currency homeCurrency"></span>
</form>
<table>
<tbody>
</tbody>
</table>
<table aria-hidden="true">
<tbody>
</tbody>
</table>
<table aria-hidden="true">
<tbody>
</tbody>
</table>
<form class="k-style">
<label><input type="radio"
name="k-style"
value=".000">.000</label>
<label><input type="radio" name="k-style" value="000" checked><small>000</small></label>
<label><input type="radio" name="k-style" value="K"><small>K</small></label>
</form>
<form>
<input type="button" onclick="window.print()" value="🖨️ Print">
</form>
</main>
</body>
<script type="module">
import { renderTemplate } from 'templateEngine.mjs';
const amounts = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80];
const defaultLocale = 'de-DE';
const homeCurrency = 'EUR';
const preselectedForeignCurrency = 'USD';
const currencyField = document.querySelector('select[name="currency"]');
const rateField = document.querySelector('input[name="rate"]');
const rateLabel = document.querySelector('label:has(input[name="rate"]) output');
const foreignAmountField = document.querySelector('input[name="foreignAmount"]');
const homeAmountField = document.querySelector('input[name="homeAmount"]');
const thousandsButton = document.querySelector('input[name="k"]');
const format = ({
amount,
currency,
withDecimals = true,
withSymbol = false
}) => {
let formatter = new Intl.NumberFormat(defaultLocale, {
style: 'currency',
currency,
maximumFractionDigits: withDecimals ? 2 : 0
});
let result = formatter.format(amount);
if (!withSymbol) {
result = result.replace(/[^\d,. ].*$/, '').trim();
}
return result;
};
const getCurrencySymbol = (currency, locale = defaultLocale) =>
new Intl.NumberFormat(locale, { style: 'currency', currency })
.formatToParts(1)
.find(x => x.type === 'currency')
.value;
const getFlagEmoji = countryCode => {
const codePoints = countryCode
.toUpperCase()
.split('')
.map(char => 127397 + char.charCodeAt());
return String.fromCodePoint(...codePoints);
};
const getCountryFromCurrency = (currency, countries) => {
let countryCode;
let country;
switch (currency) {
case 'CHF':
case 'GBP':
case 'NOK':
case 'NZD':
case 'USD':
countryCode = currency.substring(0, 2).toLowerCase();
break;
case 'EUR':
return {
countryCode: 'eu',
country_name: 'european union'
};
case 'XAF':
return {
countryCode: 'cm',
country_name: 'african financial community'
};
case 'XCD':
return {
countryCode: 'lc',
country_name: 'organisation of eastern caribbean states'
};
case 'XOF':
return {
countryCode: 'ci',
country_name: 'west african economic and monetary union'
};
default:
country = countries.find(c => c[1].currency_code === currency.toLowerCase());
countryCode = country?.[0];
break;
}
if (!country) {
country = countries.find(c => c[0] === countryCode);
}
if (!country) {
return null;
}
return {
...country[1],
countryCode
};
};
const targetTables = [...document.querySelectorAll('table')];
let currencies = {};
const loadFromCDN = async ({
apiVersion = 'v1',
date = 'latest',
endpoint
}) => {
try {
return await fetch(`https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@${date}/${apiVersion}/${endpoint}`);
} catch {
return await fetch(`https://${date}.currency-api.pages.dev/${apiVersion}/${endpoint}`);
}
};
const loadRates = async () => {
let ratesRequest = await loadFromCDN({ endpoint: `currencies/${homeCurrency.toLowerCase()}.min.json` });
let rates = await ratesRequest.json();
let date = Date.parse(rates.date);
let updateDate = new Intl.DateTimeFormat(defaultLocale).format(date);
targetTables.forEach(target => {
renderTemplate({
templateId: 'table-foot',
data: { updateDate },
target
});
});
let exchangeRates = rates[homeCurrency.toLowerCase()];
let countriesRequest = await loadFromCDN({ endpoint: 'country.json' });
let countries = Object.entries(await countriesRequest.json());
let namesRequest = await loadFromCDN({ endpoint: 'currencies.min.json' });
let currencyNames = await namesRequest.json();
let nativelySupported = Intl.supportedValuesOf('currency');
for (let currency in exchangeRates) {
currency = currency.toUpperCase();
let isSupported = nativelySupported.includes(currency);
let country = getCountryFromCurrency(currency, countries);
isSupported = isSupported && Boolean(country);
if (!isSupported) { continue; }
let flag = getFlagEmoji(country.countryCode);
isSupported = isSupported && Boolean(flag);
if (!isSupported) { continue; }
currencies[currency] = {
rate: exchangeRates[currency.toLowerCase()],
name: currencyNames[currency.toLowerCase()],
country: {
...country,
flag
}
};
}
let select = document.querySelector('[name="currency"]');
Object.keys(currencies).forEach(currency => {
if (currency === homeCurrency) { return; }
let option = document.createElement('option');
option.value = currency;
option.selected = option.value === preselectedForeignCurrency;
let country = currencies[currency].country;
let countryName = country.country_name;
countryName = countryName.replace(/\(.*/, '').trim();
countryName = countryName.replace('cura\u{FFFD}ao', 'curaçao');
countryName = countryName.replace('c\u{FFFD}te', 'côte');
countryName = countryName.replaceAll(/(^| )([^ ]+)/g, function (match, space, word) {
if (word === 'and' || word === 'of') {
return match;
}
if (word === 'd\'ivoire') {
return ' d\'Ivoire';
}
return `${space}${word[0].toUpperCase()}${word.slice(1)}`;
});
// console.debug(currency, currencies[currency].rate);
option.textContent = `${country.flag} ${option.value} - ${currencies[currency].name} (${countryName})`;
select.appendChild(option);
});
// window.currencies = currencies;
let homeSymbol = getCurrencySymbol(homeCurrency);
let homeFlag = currencies[homeCurrency].country.flag;
document.querySelector('.homeCurrency').innerHTML = `${homeSymbol} <span class="flag">${homeFlag}</span>`;
};
const getMultiplier = () => {
const rate = Number(rateField.value);
let multiplier = 1 / 100;
while (multiplier / rate < .175) {
multiplier *= 10;
}
return multiplier;
};
const renderTable = () => {
document.querySelectorAll('thead, tbody tr').forEach(row => {
row.remove();
});
const currency = currencyField.value;
const rate = Number(rateField.value);
const multiplier = getMultiplier();
targetTables.forEach(target => {
renderTemplate({
templateId: 'table-head',
data: {
foreignFlag: currencies[currency].country.flag,
foreignCurrency: currency,
homeCurrency,
homeFlag: currencies[homeCurrency].country.flag
},
target,
prepend: true
});
let rowCount = Math.ceil(amounts.length / 2);
for (let i = 0; i < rowCount; i++) {
renderTemplate({
templateId: 'table-row',
data: {
foreignAmount1: format({ amount: amounts[i] * multiplier, currency, withDecimals: multiplier < 1 }),
homeAmount1: format({ amount: amounts[i] * multiplier / rate, currency: homeCurrency }),
foreignAmount2: format({
amount: amounts[i + rowCount] * multiplier,
currency,
withDecimals: multiplier < 1
}),
homeAmount2: format({
amount: amounts[i + rowCount] * multiplier / rate,
currency: homeCurrency
})
},
target: target.querySelector('tbody')
});
}
if (multiplier >= 1_000_000) {
target.querySelectorAll('tr td:nth-child(odd)').forEach(cell => {
cell.innerHTML = cell.innerHTML.replace(/([.,\s]?000){2}$/, '<small class="thousands"></small>');
});
} else if (multiplier >= 1_000) {
target.querySelectorAll('tr td:nth-child(odd)').forEach(cell => {
cell.innerHTML = cell.innerHTML.replace(/[.,\s]?000$/, '<small class="thousands"></small>');
});
}
});
};
const parseAmount = (value) => {
value = value.replace(/k$/i, '000');
if (/\.\d{3,}/.test(value) || /\..+,/.test(value)) {
value = value.replaceAll('.', '');
} else if (/,\d{3,}/.test(value)) {
value = value.replaceAll(',', '');
}
if (/,\d{1,2}/.test(value)) {
value = value.replace(',', '.');
}
let result = Number(value);
if (isFinite(result)) {
return result;
}
return null;
};
const handleCurrencyChange = () => {
saveToStorage();
let currency = currencyField.value;
let currencySymbol = getCurrencySymbol(currency);
document.querySelector('.foreignCurrency').innerHTML = `${currencySymbol} <span class="flag">${currencies[currency].country.flag}</span>`;
let { rate } = currencies[currency];
rateField.value = rate;
rateLabel.textContent = new Intl.NumberFormat(defaultLocale, { maximumFractionDigits: 100 }).format(rate);
const multiplier = getMultiplier();
if (multiplier >= 1_000_000) {
document.body.classList.add('show-k');
document.body.classList.add('millions');
thousandsButton.checked = true;
} else if (multiplier >= 1_000) {
document.body.classList.add('show-k');
document.body.classList.remove('millions');
thousandsButton.checked = true;
} else {
document.body.classList.remove('show-k');
document.body.classList.remove('millions');
thousandsButton.checked = false;
}
updateForeignAmountField();
renderTable();
let amount = parseAmount(homeAmountField.value);
if (!amount) {
homeAmountField.value = format({ amount: 1, currency: homeCurrency });
}
handleAmountInput(homeAmountField);
};
const handleAmountInput = (field) => {
const rate = Number(rateField.value);
const kClicked = field === foreignAmountField && thousandsButton.checked;
let { value } = field;
if (kClicked && field.value.toLowerCase().endsWith('k')) {
value = value.replace(/k$/i, '');
field.value = value;
}
let amount = parseAmount(value);
if (typeof amount !== 'number') {
return;
}
if (kClicked) {
amount *= 1_000;
}
if (value.toLowerCase().endsWith('k')) {
console.log(amount);
field.value = format({
amount: kClicked ? amount / 1_000 : amount,
currency: field === homeAmountField ? homeCurrency : currency, withDecimals: false
});
}
if (field === homeAmountField) {
foreignAmountField.value = format({
amount: amount * rate,
currency: currencyField.value,
withDecimals: rate < 20
});
foreignAmountField.closest('.suffix-input').dataset.after = '';
} else {
if (kClicked) {
foreignAmountField.closest('.suffix-input').dataset.after = '.000';
}
homeAmountField.value = format({ amount: amount / rate, currency: homeCurrency });
}
};
const currencyStorageKey = location.pathname + '#currency';
const kStyleStorageKey = location.pathname + '#kStyle';
const loadFromStorage = () => {
let stored = localStorage.getItem(currencyStorageKey);
if (stored) {
currencyField.value = stored;
}
stored = localStorage.getItem(kStyleStorageKey);
if (stored) {
document.querySelectorAll('[name="k-style"]').forEach(input => input.checked = input.value === stored);
}
};
const saveToStorage = () => {
localStorage[currencyStorageKey] = currencyField.value;
localStorage[kStyleStorageKey] = document.querySelector('[name="k-style"]:checked').value;
};
const updateForeignAmountField = () => {
let clicked = thousandsButton.checked;
let span = foreignAmountField.closest('.suffix-input');
if (clicked) {
span.dataset.after = '.000';
} else {
span.dataset.after = '';
}
};
const handleKClick = () => {
updateForeignAmountField();
handleAmountInput(foreignAmountField);
};
const handleAmountFocusChange = (hasFocus) => {
thousandsButton.disabled = !hasFocus;
if (hasFocus) {
updateForeignAmountField();
}
};
currencyField.addEventListener('input', handleCurrencyChange);
foreignAmountField.addEventListener('input', handleAmountInput.bind(null, foreignAmountField));
homeAmountField.addEventListener('input', handleAmountInput.bind(null, homeAmountField));
foreignAmountField.addEventListener('focus', handleAmountFocusChange.bind(null, true));
homeAmountField.addEventListener('input', handleAmountFocusChange.bind(null, false));
currencyField.addEventListener('focus', handleAmountFocusChange.bind(null, false));
thousandsButton.addEventListener('change', handleKClick);
document.querySelectorAll('[name="k-style"]').forEach(input => input.addEventListener('change', saveToStorage));
document.querySelectorAll('form').forEach((form) => {
form.addEventListener('submit', (e) => {
e.preventDefault();
});
});
await loadRates();
document.body.classList.remove('loading');
let urlParam = new URLSearchParams(location.search).get('currency');
if (urlParam) {
currencyField.value = urlParam;
history.replaceState({}, '', location.pathname);
} else {
loadFromStorage();
}
let currency = currencyField.value;
if (!currencies[currency]) {
currencyField.value = 'USD';
}
handleCurrencyChange();
</script>
</html>
/*
* Copyright 2025 Thomas Rosenau
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
form, form * {
&, &::before, &::after {
box-sizing: border-box;
}
}
:root {
--button-hover-background: color-mix(in srgb, var(--form-theme-color) 90%, black);
--button-padding-active: calc(.5rem + 1px) .5rem calc(.5rem - 1px) .5rem;
--button-shadow-active: inset 0 0 .4em #0005, 0 0 0 transparent, 0 0 0 transparent;
--button-shadow-idle: inset 0 0 transparent, 1px 1px 4px #0003, -1px -1px 4px white;
--form-background-color: #edefee;
--form-border-style: 1px solid #0006;
--form-contrast-color: white;
--form-disabled-color: color-mix(in srgb, var(--form-theme-color) 50%, gray);
--form-field-radius: .35rem;
--form-focus-color: color-mix(in srgb, var(--form-theme-color) 80%, black);
--form-theme-color: #1b69ee;
--form-transition: border-color ease-in-out .2s, opacity ease-in-out .3s, box-shadow ease-in-out .1s, padding linear .1s;
}
/* layout */
form {
line-height: 1.25;
padding: 1rem;
border-radius: .5rem;
background: var(--form-background-color);
}
form, fieldset {
display: flex;
flex-direction: column;
justify-content: stretch;
gap: 1.5em;
p {
margin: 0;
}
}
fieldset {
padding: 1rem;
border: 1px solid #0002;
border-radius: .25rem;
legend {
font-weight: bold;
color: #747474;
}
}
label:not(:has(input, select, textarea, button, output)) {
display: block;
&:not(:last-child) {
margin-bottom: .5em;
:is(form, fieldset) > & {
margin-bottom: -1em;
}
}
}
label:has(input, select, textarea) {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
vertical-align: middle;
gap: .5em;
}
input:not([type]),
input:is(
[type="search"], [type="submit"], [type="button"], [type="reset"], [type="range"],
[type="text"], [type="email"], [type="password"], [type="number"], [type="tel"], [type="url"], [type="file"],
[type="date"], [type="time"], [type|="datetime"], [type="month"], [type="week"]
), select, textarea, button, .suffix-input, .prefix-input, label:has(.toggle-button) {
flex: 1;
width: 100%;
min-width: 100%;
max-width: 100%;
padding: 0.5rem;
}
/* appearance */
input, select, button, textarea, .prefix-input, .suffix-input {
font-family: inherit;
font-size: inherit;
line-height: inherit;
margin: 0;
}
input:not([type]),
input:is(
[type="search"], [type="submit"], [type="button"], [type="reset"], [type="range"],
[type="text"], [type="email"], [type="password"], [type="number"], [type="tel"], [type="url"], [type="file"],
[type="date"], [type="time"], [type|="datetime"], [type="month"], [type="week"]
), select, textarea, button, .suffix-input, .prefix-input, .toggle-button {
appearance: none;
border: var(--form-border-style);
border-radius: var(--form-field-radius);
background: Field;
transition: var(--form-transition);
}
button, input:is([type="submit"], [type="button"], [type="reset"], .toggle-button) {
--inner-shadow: var(--button-shadow-idle);
appearance: none;
color: var(--form-contrast-color);
border: var(--form-border-style);
background: var(--form-theme-color);
box-shadow: var(--inner-shadow);
&:disabled {
background: var(--form-disabled-color);
}
&:enabled:active {
--inner-shadow: var(--button-shadow-active);
padding: var(--button-padding-active);
}
&:focus-visible {
outline-offset: 2px;
box-shadow: var(--focus-shadow), var(--inner-shadow);
}
}
select:not([multiple]) {
padding-right: 2rem;
background: Field url("data:image/svg+xml;utf8,<svg height='12' viewBox='0 -1 20 20' width='12' xmlns='http://www.w3.org/2000/svg'><path fill='FieldText' d='M5.516 7.548l4.484 4.484 4.484-4.484-1.06-1.06L10 9.912 6.576 6.488z'/></svg>") no-repeat right .75rem center;
background-size: 1rem;
}
/* disabled */
input, select, button, textarea, .prefix-input, .suffix-input {
&:disabled {
opacity: 0.5;
}
}
/* hover */
label:has(input:is([type="checkbox"], [type="radio"]):enabled) {
cursor: pointer;
}
select, button, input:is([type="file"], [type="checkbox"], [type="radio"], [type="submit"], [type="image"], [type="reset"], [type="button"]) {
&:enabled {
cursor: pointer;
}
&:disabled {
cursor: default;
}
}
input:not([type]),
input:is(
[type="search"],
[type="text"], [type="email"], [type="password"], [type="number"], [type="tel"], [type="url"],
[type="date"], [type="time"], [type|="datetime"], [type="month"], [type="week"]
),
select, textarea, .suffix-input, .prefix-input {
&:hover:not(:disabled, :has(:disabled)) {
border-color: var(--form-theme-color);
}
}
input:is([type="button"], [type="submit"], [type="reset"]), button, .toggle-button {
&:hover:enabled {
background-color: var(--button-hover-background);
}
}
/* focus */
:is(input, button, textarea, select, .toggle-button):focus-visible,
:is(.suffix-input, .prefix-input):has(:focus-visible) {
--focus-shadow: 0 0 1px 3px #fff6;
outline: 2px solid var(--form-focus-color);
outline-offset: 0;
box-shadow: var(--focus-shadow);
}
input:is([type="image"], [type="file"], [type="checkbox"]:not(.toggle-button), [type="range"], [type="radio"]):focus-visible {
outline-offset: 2px;
box-shadow: none;
}
/* radio */
input[type="radio"] {
--size: 1.2em;
appearance: none;
position: relative;
width: var(--size);
height: var(--size);
margin: 0;
border: var(--form-border-style);
border-radius: 50%;
background: Field;
transition: var(--form-transition);
&:hover:enabled {
border-color: var(--form-theme-color);
}
&:checked::before {
content: "";
position: absolute;
inset: calc(var(--size) / 6);
border-radius: 50%;
background: var(--form-theme-color);
}
&:disabled {
opacity: .5;
&:checked::before {
background-color: var(--form-disabled-color);
}
}
}
/* checkbox */
input[type="checkbox"]:not(.toggle):not(.toggle-button) {
--size: 1.2em;
appearance: none;
display: inline-block;
position: relative;
width: var(--size);
height: var(--size);
margin: 0;
border: var(--form-border-style);
border-radius: 16%;
background: Field;
transition: var(--form-transition);
&:disabled {
opacity: .5;
}
&:hover:enabled {
border-color: var(--form-theme-color);
}
&:checked {
border-color: var(--form-theme-color);
background: var(--form-theme-color);
&:disabled {
border-color: var(--form-disabled-color);
background: var(--form-disabled-color);
}
&::after {
content: "";
position: absolute;
top: calc(var(--size) / 10);
left: calc(var(--size) * 2 / 7);
width: calc(var(--size) / 3);
height: calc(var(--size) * 7 / 12);
transform: rotate(45deg);
border: solid var(--form-contrast-color);
border-width: 0 calc(var(--size) / 7) calc(var(--size) / 7) 0;
}
}
}
/* custom input: toggle */
input[type="checkbox"].toggle {
--size: 1.2em;
appearance: none;
position: relative;
width: calc(25 / 14 * var(--size));
height: var(--size);
margin: 0;
border-radius: var(--size);
background: hsl(0, 0%, 65%);
transition: background 0.3s ease;
&::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: calc(10 / 14 * var(--size));
margin: calc(2 / 14 * var(--size));
border-radius: 50%;
background: var(--form-contrast-color);
transition: left 0.3s;
}
&:checked {
background: var(--form-theme-color);
&:disabled {
background: var(--form-disabled-color);
}
&::before {
left: calc(11 / 14 * var(--size));
}
}
&:active:enabled {
&::before {
left: calc(11 / 14 / 2 * var(--size));
}
}
}
/* range slider */
input[type="range"] {
--border-width: calc((var(--size) - var(--thumb-size)) / 2);
--size: 1.2em;
--thumb-size: calc(.6 * var(--size));
-webkit-appearance: none;
overflow: hidden;
height: var(--size);
padding: 0;
border-radius: calc(var(--size) / 2);
background: var(--form-theme-color);
border-color: var(--form-theme-color);
/* do not combine ::-webkit-slider-thumb and ::-moz-range-thumb, this will break Chrome */
&::-webkit-slider-thumb {
-webkit-appearance: none;
box-sizing: content-box;
width: var(--thumb-size);
height: var(--thumb-size);
border: var(--border-width) solid var(--form-theme-color);
border-radius: 50%;
background: var(--form-contrast-color);
box-shadow: 100vw 0 0 calc(100vw - var(--size) / 2) Field;
}
&::-moz-range-thumb {
appearance: none;
box-sizing: content-box;
width: var(--thumb-size);
height: var(--thumb-size);
border: var(--border-width) solid var(--form-theme-color);
border-radius: 50%;
background: var(--form-contrast-color);
box-shadow: 100vw 0 0 calc(100vw - var(--size) / 2) Field;
}
&:focus-visible {
outline-offset: 2px;
}
&:hover:enabled {
border-color: var(--form-theme-color);
}
&:enabled {
&::-webkit-slider-thumb {
cursor: pointer;
}
&::-moz-range-thumb {
cursor: pointer;
}
}
&:disabled {
opacity: .5;
}
}
/* custom inputs: prefix-input and suffix-input */
.suffix-input, .prefix-input {
display: flex;
align-items: center;
justify-content: flex-start;
padding: 0;
&:not(:has(:disabled)) {
cursor: text;
}
&:has(:disabled) {
opacity: 0.5;
}
input {
flex: 1;
min-width: 0;
padding: .5rem;
opacity: unset;
border: none;
border-radius: 0;
background: none;
&:focus-visible {
outline: none;
box-shadow: none;
}
}
}
.suffix-input {
padding-right: .5rem;
input {
padding-right: 0;
text-align: right;
}
&:after {
content: attr(data-after);
opacity: .5;
}
}
.prefix-input {
padding-left: .5rem;
input {
padding-left: 0;
}
&:before {
content: attr(data-before);
opacity: .5;
}
}
/* custom input: toggle-button */
label:has(input.toggle-button) {
position: relative;
justify-content: center;
user-select: none;
color: inherit;
border: 1px solid transparent;
transition: var(--form-transition), color .3s ease;
isolation: isolate;
&:has(:checked, :active:enabled) {
color: var(--form-contrast-color);
}
&:has(:is(:checked, :active:enabled)) {
padding: var(--button-padding-active);
}
&:has(:disabled) {
opacity: .5;
}
}
input.toggle-button {
z-index: -1;
position: absolute;
inset: -1px;
width: auto;
min-width: 0;
height: auto;
min-height: 0;
margin: 0;
background: transparent;
transition: var(--form-transition), background 0.3s ease;
&:is(:active, :checked):enabled {
--inner-shadow: var(--button-shadow-active);
background: var(--form-theme-color);
}
&:not(:disabled, :checked, :active):hover {
background: color-mix(in lab, var(--form-theme-color) 10%, transparent);
background: #0001;
}
&:checked:enabled:hover:not(:active) {
background: var(--button-hover-background);
}
&:disabled {
opacity: unset;
background: var(--form-disabled-color);
&:not(:checked) {
background: transparent;
}
}
}
@media screen {
form {
box-shadow: 0 0 5px #cccc;
}
}
/*
* Copyright 2025 Thomas Rosenau
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
const escapeHTML = (str) => String(str).replaceAll(
/[&<>"']/g,
(char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', '\'': '&#39;' }[char])
);
const parseHTMLFragment = (html) => {
let contextTag = html.match(/<([a-z]+)/i)?.[1] || 'div';
if (['thead', 'tfoot', 'tbody', 'tr', 'td', 'th'].includes(contextTag)) {
const table = document.createElement('table');
table.innerHTML = html;
return table.querySelector(contextTag);
}
return document.createRange().createContextualFragment(html);
};
export const renderTemplate = ({ templateId, data, target, prepend = false }) => {
let template = document.querySelector(`script[type="text/template"]#${templateId}`).innerHTML;
const conditionMatcher = /\{\{\?(\w+)}}(.*?)\{\{\/\1}}/sg;
const negativeConditionMatcher = /\{\{!(\w+)}}(.*?)\{\{\/\1}}/sg;
while (conditionMatcher.test(template) || negativeConditionMatcher.test(template)) {
template = template.replaceAll(conditionMatcher,
(_, key, content) => [false, null, undefined].includes(data[key]) ? '' : content
);
template = template.replaceAll(negativeConditionMatcher,
(_, key, content) => [false, null, undefined].includes(data[key]) ? content : ''
);
}
let rendered = template.replaceAll(/\{\{(\w+)}}/g, (_, key) => escapeHTML(data[key] ?? ''));
if (typeof target === 'string') {
target = document.querySelector(target);
}
let fragment = parseHTMLFragment(rendered);
if (prepend) {
target.insertBefore(fragment, target.firstChild);
} else {
target.appendChild(fragment);
}
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment