
On This Page
Building a Powerful Currency Input Component with shadcn-svelte and Svelte 5
Have you ever tried to build a proper currency input field? One that formats numbers correctly, handles decimals appropriately, and displays the right currency symbol? If so, you know it's surprisingly challenging to get right.
In this comprehensive guide, we'll tackle this common UI challenge by building a robust, reusable currency input component using shadcn-svelte and Svelte 5's powerful new runes syntax. By the end, you'll have a component that your users will love and that you can drop into any part of your application.
The Problem with Currency Inputs
Standard HTML inputs aren't designed to handle currency values elegantly. Consider these common issues:
- Users enter "1000" but you need to store "10.00"
- Different locales use different decimal separators (period vs. comma)
- Currency symbols need to be displayed but not included in the actual value
- Formatting needs to happen in real-time as the user types
Let's solve all these problems with a single, elegant component.
What We'll Build
Our currency input component will have these powerful features:
- ✅ Automatic currency formatting as the user types
- ✅ Locale-aware formatting for international applications
- ✅ Proper decimal handling with configurable precision
- ✅ Currency symbol display that doesn't interfere with the input
- ✅ Full keyboard support for a seamless user experience
- ✅ Seamless integration with shadcn-svelte's theming system
- ✅ Svelte 5 runes for clean, reactive code
Prerequisites
Before we dive in, make sure you have:
- A Svelte 5 project set up
- shadcn-svelte installed in your project
- Basic understanding of Svelte components and TypeScript
If you're new to shadcn-svelte, check out the official documentation to get started.
Implementation: Building the Component
Let's create our currency input component step by step. We'll build it on top of shadcn-svelte's base Input component and add our currency-specific functionality.
<script lang="ts">
import type { HTMLInputAttributes } from 'svelte/elements';
import type { WithElementRef } from 'bits-ui';
import { Input, type InputSize, type InputVariant } from '$components/ui/input/index.js';
import { cn } from '$lib/utils/ui.js';
export type InputPriceProps = WithElementRef<HTMLInputAttributes> & {
variant?: InputVariant;
inputSize?: InputSize;
currencyFormat?: Intl.NumberFormat;
};
let {
ref = $bindable(null),
value = $bindable(''),
currencyFormat = new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 2,
maximumFractionDigits: 2
}),
...restProps
}: InputPriceProps = $props();
// Extract currency symbol
let currencySymbol = $derived.by(() => {
const formatted = currencyFormat.format(0);
return formatted.replace(/[d.,s]/g, '').trim();
});
function formatCurrency(value: number) {
return currencyFormat.format(value).replace(currencySymbol, '').trim();
}
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
const numericValue = Number(target.value.replace(/D/g, '')) / 100;
value = formatCurrency(numericValue);
}
function handleFocus(event: FocusEvent) {
const target = event.target as HTMLInputElement;
target.setSelectionRange(target.value.length, target.value.length);
}
</script>
<div class="flex-auto">
<div class="relative flex items-center">
<div class="text-muted-foreground pointer-events-none absolute left-3 flex items-center">
{currencySymbol}
</div>
<Input
bind:value
placeholder="0,00"
autocomplete="off"
maxlength={22}
inputmode="decimal"
type="text"
class={cn('pl-6', restProps.class)}
oninput={handleInput}
onfocus={handleFocus}
{ref}
{...restProps}
/>
</div>
</div>
Now let's also create an index file to export our component:
import CurrencyInput from './currency-input.svelte';
export { CurrencyInput };
export type { InputPriceProps } from './currency-input.svelte';
How It Works: Breaking Down the Code
Let's dive deep into how this component works, section by section:
1. Props and Types
Our component accepts all standard HTML input attributes plus some custom props:
export type InputPriceProps = WithElementRef<HTMLInputAttributes> & {
variant?: InputVariant;
inputSize?: InputSize;
currencyFormat?: Intl.NumberFormat;
};
variant
: Matches shadcn's input variants (outline, ghost, etc.)inputSize
: Controls the input size (sm, md, lg)currencyFormat
: Allows customizing the currency format using the browser'sIntl.NumberFormat
API
2. Svelte 5 Runes
We're using Svelte 5's new runes syntax for cleaner, more maintainable code:
let {
ref = $bindable(null),
value = $bindable('')
// ...
}: InputPriceProps = $props();
The $bindable
rune creates a two-way binding for our value, while $props()
handles component props in a more elegant way than previous Svelte versions.
3. Currency Formatting with Intl.NumberFormat
We leverage the browser's built-in internationalization API for robust currency formatting:
currencyFormat = new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
This gives us locale-aware formatting with proper decimal separators, grouping, and currency symbols.
4. Automatic Currency Symbol Extraction
Instead of hardcoding the currency symbol, we extract it dynamically using a derived state:
let currencySymbol = $derived.by(() => {
const formatted = currencyFormat.format(0);
return formatted.replace(/[d.,s]/g, '').trim();
});
This approach works for any currency, whether it's €, $, £, ¥, or any other symbol, and handles their correct positioning based on locale.
5. Smart Input Handling
The magic happens in our input handler:
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
const numericValue = Number(target.value.replace(/D/g, '')) / 100;
value = formatCurrency(numericValue);
}
This function:
- Strips all non-digit characters from the input
- Converts the resulting string to a number
- Divides by 100 to handle decimal places correctly
- Formats the value using our currency formatter
- Updates the input value with the formatted result
6. Cursor Positioning
We also handle cursor positioning to ensure a smooth user experience:
function handleFocus(event: FocusEvent) {
const target = event.target as HTMLInputElement;
target.setSelectionRange(target.value.length, target.value.length);
}
This places the cursor at the end of the input when the user focuses on it, which is the most intuitive behavior for a currency input.
Real-World Usage Examples
Let's look at some practical examples of how to use our component in different scenarios:
Basic Usage
The simplest way to use our component:
<script lang="ts">
import { CurrencyInput } from '$components/ui/currency-input';
let price = '';
function handleSubmit() {
console.log('Submitted price:', price);
// Process the payment...
}
</script>
<form on:submit|preventDefault={handleSubmit}>
<label for="price">Amount to pay</label>
<CurrencyInput id="price" bind:value={price} />
<button type="submit">Pay Now</button>
</form>
Custom Currency and Locale
For an international application, you might want to use different currencies based on the user's locale:
<script lang="ts">
import { CurrencyInput } from '$components/ui/currency-input';
let price = '';
let selectedCurrency = 'USD';
let selectedLocale = 'en-US';
// Available options for the user to select
const currencies = [
{ code: 'USD', locale: 'en-US', label: 'US Dollar ($)' },
{ code: 'EUR', locale: 'de-DE', label: 'Euro (€)' },
{ code: 'GBP', locale: 'en-GB', label: 'British Pound (£)' },
{ code: 'JPY', locale: 'ja-JP', label: 'Japanese Yen (¥)' }
];
// Create a reactive formatter based on selection
$: currencyFormatter = new Intl.NumberFormat(selectedLocale, {
style: 'currency',
currency: selectedCurrency,
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
</script>
<div class="space-y-4">
<div>
<label for="currency">Select Currency</label>
<select
id="currency"
bind:value={selectedCurrency}
on:change={(e) => {
const selected = currencies.find((c) => c.code === selectedCurrency);
if (selected) selectedLocale = selected.locale;
}}
>
{#each currencies as currency}
<option value={currency.code}>{currency.label}</option>
{/each}
</select>
</div>
<div>
<label for="price">Amount</label>
<CurrencyInput id="price" bind:value={price} currencyFormat={currencyFormatter} />
</div>
</div>
Integration with Form Validation
Here's how you might integrate our component with a form validation library like Zod:
<script lang="ts">
import { CurrencyInput } from '$components/ui/currency-input';
import { z } from 'zod';
import { superForm } from 'sveltekit-superforms';
// Define the schema
const schema = z.object({
amount: z
.string()
.min(1, 'Amount is required')
.refine((val) => {
// Extract numeric value for validation
const numericValue = Number(val.replace(/D/g, '')) / 100;
return numericValue >= 5; // Minimum amount of 5
}, 'Minimum amount is 5')
});
// Create the form
const { form, errors, enhance } = superForm({
schema,
initialValues: {
amount: ''
}
});
</script>
<form method="POST" use:enhance>
<div class="space-y-2">
<label for="amount">Payment Amount</label>
<CurrencyInput
id="amount"
bind:value={$form.amount}
aria-invalid={$errors.amount ? 'true' : undefined}
/>
{#if $errors.amount}
<p class="text-red-500 text-sm">{$errors.amount}</p>
{/if}
</div>
<button type="submit" class="mt-4">Process Payment</button>
</form>
Custom Styling with shadcn Variants
Our component works seamlessly with shadcn's variant system:
<script lang="ts">
import { CurrencyInput } from '$components/ui/currency-input';
let donation = '';
</script>
<div class="space-y-4">
<h2>Choose a donation amount</h2>
<div class="grid grid-cols-3 gap-2">
<button class="btn variant-filled" on:click={() => (donation = '10,00')}> €10 </button>
<button class="btn variant-filled" on:click={() => (donation = '20,00')}> €20 </button>
<button class="btn variant-filled" on:click={() => (donation = '50,00')}> €50 </button>
</div>
<div>
<label for="custom-amount">Or enter custom amount:</label>
<CurrencyInput
id="custom-amount"
bind:value={donation}
variant="outline"
inputSize="lg"
class="w-full border-primary"
/>
</div>
</div>
Advanced Customization
Right-aligned Currency Symbol
Some UIs prefer the currency symbol on the right. Here's how to modify our component:
<!-- Similar to original component but with modified template -->
<div class="flex-auto">
<div class="relative flex items-center">
<Input
bind:value
placeholder="0,00"
autocomplete="off"
maxlength={22}
inputmode="decimal"
type="text"
class={cn('pr-6', restProps.class)}
oninput={handleInput}
onfocus={handleFocus}
{ref}
{...restProps}
/>
<div class="text-muted-foreground pointer-events-none absolute right-3 flex items-center">
{currencySymbol}
</div>
</div>
</div>
Adding Input Masks
For even better user experience, you might want to add an input mask. Here's how you could extend our component with a simple mask:
// Add to the script section
import { createMask } from 'imask';
// Create a mask based on the currency format
$: mask = createMask({
mask: Number,
scale: 2,
signed: false,
thousandsSeparator: currencyFormat.format(1000).charAt(1),
radix: currencyFormat.format(0.1).charAt(1),
mapToRadix: ['.', ',']
});
// Then use the mask in your input handler
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
const maskedValue = mask(target.value);
// Process the masked value...
}
Performance Considerations
Our component is designed to be efficient, but here are some tips for optimal performance:
- Memoize currency formatters if you're creating multiple instances with the same format
- Debounce input events if you're performing expensive operations on each input
- Use
requestAnimationFrame
for smoother formatting during rapid typing
Here's an example of debouncing the input:
import { debounce } from 'lodash-es';
// Create a debounced version of the input handler
const debouncedHandleInput = debounce((event: Event) => {
const target = event.target as HTMLInputElement;
const numericValue = Number(target.value.replace(/D/g, '')) / 100;
value = formatCurrency(numericValue);
}, 100); // 100ms delay
// Use the debounced handler for rapid typing
function handleInput(event: Event) {
debouncedHandleInput(event);
}
Accessibility Considerations
To ensure our component is accessible to all users:
- Always use labels with your inputs
- Set appropriate ARIA attributes for screen readers
- Ensure keyboard navigation works correctly
- Test with screen readers to verify the experience
Here's an enhanced version with better accessibility:
<div class="flex-auto">
<div class="relative flex items-center">
<div
class="text-muted-foreground pointer-events-none absolute left-3 flex items-center"
aria-hidden="true"
<!--
Hide
from
screen
readers
--
>
>
{currencySymbol}
</div>
<Input
bind:value
placeholder="0,00"
autocomplete="off"
maxlength={22}
inputmode="decimal"
type="text"
class={cn('pl-6', restProps.class)}
oninput={handleInput}
onfocus={handleFocus}
aria-describedby={`${id}-hint`}
{ref}
{...restProps}
/>
</div>
{#if hint}
<div id={`${id}-hint`} class="text-sm text-muted-foreground mt-1">
{hint}
</div>
{/if}
</div>
Testing Your Component
Here's a simple test suite using Vitest and Testing Library:
import { render, fireEvent, screen } from '@testing-library/svelte';
import { describe, it, expect } from 'vitest';
import CurrencyInput from './currency-input.svelte';
describe('CurrencyInput', () => {
it('renders with default currency format (EUR)', () => {
render(CurrencyInput, { props: { value: '' } });
const input = screen.getByRole('textbox');
expect(document.body.textContent).toContain('€');
expect(input).toHaveValue('');
});
it('formats input correctly', async () => {
render(CurrencyInput, { props: { value: '' } });
const input = screen.getByRole('textbox');
await fireEvent.input(input, { target: { value: '1234' } });
expect(input).toHaveValue('12,34');
await fireEvent.input(input, { target: { value: '123456' } });
expect(input).toHaveValue('1.234,56');
});
it('works with custom currency format (USD)', async () => {
const usdFormatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD'
});
render(CurrencyInput, {
props: {
value: '',
currencyFormat: usdFormatter
}
});
expect(document.body.textContent).toContain('$');
const input = screen.getByRole('textbox');
await fireEvent.input(input, { target: { value: '1234' } });
expect(input).toHaveValue('12.34');
});
});
Real-World Applications
Our currency input component is perfect for:
- E-commerce checkout pages for entering payment amounts
- Donation forms with suggested and custom amounts
- Financial applications dealing with monetary values
- Expense tracking apps for entering costs
- Invoicing software for line item amounts
Conclusion
Building a robust currency input component might seem like a small detail, but it significantly improves the user experience in any application dealing with money. With shadcn-svelte and Svelte 5's runes, we've created a reusable, accessible, and highly customizable component that handles all the complexities of currency formatting.
The component we've built is just a starting point. Feel free to extend it with additional features like:
- Input validation with min/max values
- Support for negative values
- Different currency symbol positions
- Integration with form libraries
- Custom formatting options
Resources
- shadcn-svelte Documentation
- Svelte 5 Runes Documentation
- Intl.NumberFormat API
- ARIA Practices for Form Inputs
Have you built a similar component or have ideas for improvements? Let me know in the comments below!