Building a Currency Input Component with shadcn-svelte and Svelte 5

Learn how to create a reusable currency input component using shadcn-svelte and Svelte 5's new runes syntax

Updated
Avatar Chris Jayden
Chris Jayden

Love Svelte content? ❤️

Get the tastiest bits of Svelte content delivered to your inbox. No spam, no fluffy content — just the good stuff.

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.

Currency Input Component Demo

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.

src/lib/components/ui/currency-input/currency-input.svelte
	<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:

src/lib/components/ui/currency-input/index.ts
	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's Intl.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:

  1. Strips all non-digit characters from the input
  2. Converts the resulting string to a number
  3. Divides by 100 to handle decimal places correctly
  4. Formats the value using our currency formatter
  5. 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:

src/routes/checkout/+page.svelte
	<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:

src/routes/international-checkout/+page.svelte
	<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:

src/routes/validated-payment/+page.svelte
	<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:

src/routes/styled-payment/+page.svelte
	<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:

src/lib/components/ui/currency-input/currency-input-right.svelte
	<!-- 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:

  1. Memoize currency formatters if you're creating multiple instances with the same format
  2. Debounce input events if you're performing expensive operations on each input
  3. 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:

  1. Always use labels with your inputs
  2. Set appropriate ARIA attributes for screen readers
  3. Ensure keyboard navigation works correctly
  4. 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:

src/lib/components/ui/currency-input/currency-input.test.ts
	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

Have you built a similar component or have ideas for improvements? Let me know in the comments below!