Back to Blog

riyal v1.2.0: The Saudi Riyal Symbol Toolkit for React, Vue, Svelte, and Web Components

AI Transformation Lead
  • React
  • Vue
  • Svelte
  • TypeScript
  • Open Source
  • Unicode
  • Currency
  • Saudi Arabia
  • KSA
  • npm
  • Fintech
  • riyal
  • SAR
  • U+20C1
  • Web Components
  • Next.js
  • shadcn
  • Tailwind
riyal v1.2.0 — Saudi Riyal symbol U+20C1 in React, Vue, Svelte, Web Components

The Saudi Central Bank released the official Saudi Riyal glyph in February 2025. The codepoint, U+20C1, is scheduled for Unicode 17.0 in September 2025, and operating systems will not render it natively for another release cycle after that. Until then, every Saudi fintech app, every checkout flow, every receipt, and every dashboard that wants to ship the correct symbol has to bring its own font.

riyal is that font, plus the React, Vue 3, Svelte 5, React Native, and Web Component primitives that go around it. v1.2.0 is the release where it stops being a single React component and becomes a full toolkit.

Links: npm · GitHub · Docs


Why I built this package

The short version: the same playbook that took dirham to roughly 8,000 npm downloads per month applies, line for line, to the Saudi market. The longer version is worth writing down.

The Unicode gap

Unicode codepoints take years to ship to user devices. The codepoint exists in the standard the day it lands. The fonts ship months later. The OS updates that bundle those fonts ship months after that. The user devices that run those OS versions take another year to roll over. Four to five years from codepoint assignment to native rendering on a real user's phone is the norm.

For a developer building a Saudi e-commerce checkout in 2026, "wait for Unicode 17.0 to propagate" is not a strategy. The product ships now. The symbol has to render now. So the symbol gets a web font, and the web font has to come from somewhere reliable.

riyal ships that font. It is derived from the SAMA-issued glyph released in February 2025, mapped to U+20C1, and bundled as WOFF2, WOFF, and TTF. When OS fonts catch up, you delete one line.

Why a full toolkit, not just a font

The first version of dirham was a font and a single React component. Every team that adopted it asked the same three follow-up questions:

  1. How do I format an amount in ar-AE with the symbol in the right place?
  2. How do I add 5% VAT to a cart total?
  3. How do I render this in a Vue/Angular/Svelte/Web Component context?

After answering those questions in three different Slack threads, it became clear the right shape was not a font package but a fintech toolkit. dirham grew into that toolkit. riyal was designed as that toolkit from day one.

Why Saudi Arabia specifically

Saudi Arabia runs the largest economy in the MENA region. Vision 2030, the Public Investment Fund's posture, and the Mada/Tabby/Tamara payments stack have produced a real fintech engineering market. The teams I talk to in Riyadh and Jeddah want what teams in Dubai wanted two years ago: a clean, typed, framework-agnostic primitive for the local currency that doesn't fight their Intl.NumberFormat and doesn't require their designer to draw a custom SVG.

That gap is what riyal fills. The 15% Saudi VAT default, the ar-SA formatting, the Saudi-green Tailwind palette, the SAMA-derived glyph, and the shadcn registry are all there because that is what the local market actually ships.

What dirham proved

dirham is at roughly 8K monthly downloads as of May 2026. That number matters less as a vanity metric and more as proof that the architecture works: a single TypeScript codebase, multiple framework entries, a tree-shakable bundle, an SSR-safe React layer, a Web Component fallback for everything else, a Tailwind plugin, a Next.js font helper, and a CLI. The same shape ships in riyal. Where dirham had to discover the right surface area through eight minor releases, riyal v1.2.0 starts there.


Installation

bash
pnpm add riyal # or npm install riyal # or yarn add riyal # or bun add riyal

Node ≥ 20 is required for full ICU and Intl support. All peer dependencies (react, vue, svelte, react-native, tailwindcss, next) are optional and only required for the entry you actually import.

Install via shadcn

riyal ships a shadcn-compatible registry. Pull production-ready components straight into your project:

bash
# Tailwind-styled SAR price tag (size + tone variants) npx shadcn@latest add https://riyal.js.org/r/riyal-price-tag.json # Form-grade SAR amount input (label, hint, error, masked editing) npx shadcn@latest add https://riyal.js.org/r/riyal-amount-input.json # Receipt-style cart summary (subtotal, VAT, shipping, grand total) npx shadcn@latest add https://riyal.js.org/r/riyal-checkout-summary.json

Each item drops a .tsx file into components/riyal/ so you own the source. riyal is pulled as an npm dependency for the underlying glyph, formatting, masking, and cart helpers.


Quick start

ts
import { formatRiyal, addVAT, RIYAL_UNICODE } from "riyal"; formatRiyal(2499.99); // → "ŝ 2,499.99" (en-SA, U+20C1 + thin space) formatRiyal(2499.99, { locale: "ar-SA" }); // → "٢٬٤٩٩٫٩٩ ŝ" (ar-SA, RTL placement) addVAT(100); // 115 (15% Saudi VAT) RIYAL_UNICODE; // "ŝ" (U+20C1)

React Components

tsx
import "riyal/css"; import { RiyalSymbol, RiyalIcon, RiyalPrice, AnimatedRiyalPrice, RiyalInput, useRiyalRate, } from "riyal/react";

<RiyalSymbol />

Inline span using the bundled font, sized via CSS em:

tsx
<RiyalSymbol size={24} /> <RiyalSymbol size="1.25em" weight={600} />

<RiyalIcon />

Standalone SVG icon. No font required, fully tree-shakeable, SSR-safe:

tsx
<RiyalIcon width={32} height={32} aria-label="SAR" />

<RiyalPrice />

Locale-aware formatted price:

tsx
<RiyalPrice amount={2499.99} /> <RiyalPrice amount={2499.99} locale="ar-SA" decimals={0} /> <RiyalPrice amount={1_200_000} compact />
PropDefaultDescription
amount0Numeric value
locale"en-SA"Intl locale ("en-SA" or "ar-SA")
decimals2Decimal places
compactfalseCompact notation (1.2K, 1.5M)
positionlocale-set"prefix" or "suffix"
weight"regular"Symbol stroke weight

<AnimatedRiyalPrice />

Spring-animated counter for live cart totals:

tsx
<AnimatedRiyalPrice amount={cartTotal} duration={400} />

<RiyalInput />

Controlled numeric input that displays formatRiyal while preserving the raw number:

tsx
const [value, setValue] = useState<number | "">(0); <RiyalInput value={value} onValueChange={setValue} locale="ar-SA" />;

Masked mode

Pass mask to switch the input into a format-as-you-type field with paste cleanup, Arabic-numeral normalisation, thousand-separator grouping, and caret preservation:

tsx
<RiyalInput mask value={value} onValueChange={setValue} />
User doesInput showsonValueChange receives
Types 1234"1,234"1234
Pastes "SAR 2,499.99""2,499.99"2499.99
Pastes "⃁ 2,499.99""2,499.99"2499.99
Pastes "٢٤٩٩٫٩٩""2,499.99"2499.99
Pastes "99.90 ر.س""99.90"99.9

Add allowNegative to permit a leading -. The same mask and allowNegative props are available on the Vue and Svelte versions of RiyalInput.

You can also call the underlying helper directly:

ts
import { maskRiyal, normalizeRiyalDigits } from "riyal"; const r = maskRiyal("SAR 2,499.99"); // → { value: 2499.99, display: "2,499.99", caret: 8 } normalizeRiyalDigits("٢٤٩٩"); // "2499"

useRiyalRate(target)

Tiny hook around convertFromSAR. Caches per target, refreshes hourly:

tsx
const { rate, convert, loading, error } = useRiyalRate("USD"); if (loading) return <span>Loading…</span>; if (error) return <span>Rates unavailable</span>; return <span>{convert(2499.99)} USD</span>;

Vue 3

vue
<script setup lang="ts"> import { ref } from "vue"; import { RiyalPrice, RiyalInput, useRiyalRate } from "riyal/vue"; const amount = ref<number | "">(2499.99); const usd = useRiyalRate("USD"); </script> <template> <RiyalPrice :amount="2499.99" locale="ar-SA" /> <RiyalInput v-model="amount" mask /> <span v-if="usd.rate.value"> {{ (Number(amount) * usd.rate.value).toFixed(2) }} USD </span> </template>

The Vue entry exposes the same surface as riyal/react: RiyalSymbol, RiyalIcon, RiyalPrice, AnimatedRiyalPrice, RiyalInput (with mask and allowNegative props), and the useRiyalRate composable. SSR-safe and works with Nuxt out of the box. RiyalInput uses v-model (binds to modelValue) and emits both update:modelValue and change.


Svelte 5

svelte
<script lang="ts"> import { RiyalPrice, RiyalInput, useRiyalRate } from "riyal/svelte"; let amount: number | "" = $state(2499.99); const usd = useRiyalRate("USD"); </script> <RiyalPrice amount={2499.99} locale="ar-SA" /> <RiyalInput bind:value={amount} mask /> {#if usd.rate} <span>{((amount as number) * usd.rate).toFixed(2)} USD</span> {/if}

The Svelte entry ships .svelte source so your bundler (Vite, SvelteKit) compiles it natively. Components use Svelte 5 runes ($props, $state, $derived, $effect, $bindable). The useRiyalRate composable is a rune-based factory that returns read-only getters plus a refresh() method.


Web Components

Framework-agnostic. Works with Vue, Angular, Svelte, Solid, and vanilla HTML. Registers <riyal-symbol>, <riyal-icon>, <riyal-price>, <riyal-animated-price>, and <riyal-input>:

ts
import { defineRiyalElements } from "riyal/web-component"; import "riyal/css"; defineRiyalElements();
html
<riyal-symbol size="1.25em"></riyal-symbol> <riyal-icon width="24" height="24"></riyal-icon> <riyal-price amount="2499.99" locale="ar-SA" compact></riyal-price> <riyal-animated-price amount="5000" duration="600"></riyal-animated-price> <riyal-input value="1250" locale="en-SA"></riyal-input>

Attribute reference

ElementAttributeTypeDefault
<riyal-symbol>sizeCSS length1em
<riyal-icon>width / heightnumber (px)24
<riyal-icon>aria-labelstring"Saudi Riyal"
<riyal-price>amountnumber stringrequired
<riyal-price>localeen-SA | ar-SA"en-SA"
<riyal-price>decimalsnumber2
<riyal-price>compactbooleanfalse
<riyal-animated-price>amountnumber stringrequired
<riyal-animated-price>durationnumber (ms)600
<riyal-input>valuenumber string""

Events

<riyal-input> dispatches a riyal-change CustomEvent when the value changes:

js
document.querySelector("riyal-input").addEventListener("riyal-change", (e) => { console.log(e.detail.value); // number });

Shadow DOM styling

Each element uses a closed shadow root. Override the symbol color and size with CSS custom properties exposed on the host:

css
riyal-price { --riyal-color: #006c35; /* Saudi green */ --riyal-size: 1.25rem; }

Cart and Checkout Primitives — riyal/cart

Receipt-grade math for line items and cart totals, with Saudi 15% VAT defaults baked in. This is the layer that does not exist in dirham and is the single biggest reason to choose riyal over rolling your own:

ts
import { lineItem, cartTotal, formatLineItem } from "riyal/cart"; const items = [ lineItem({ name: "Coffee Mug", unit: 45, qty: 2 }), lineItem({ name: "Filter Pack", unit: 28, qty: 1 }), ]; const totals = cartTotal(items, { shipping: 20, discount: 10 }); // → { // subtotal: 118, // sum of net // vatSubtotal: 17.7, // 15% of subtotal // discount: 10, // capped to grossSubtotal // netTotal: 109.13, // discount applied proportionally // vat: 19.36, // includes shipping VAT // shipping: 20, // total: 148.49, // itemCount: 3, // vatRate: 0.15 // } formatLineItem(items[0]).gross; // → "⃁ 103.50"

What this gets right that hand-rolled cart math gets wrong:

  • lineItem({ unit, qty, vatIncluded?, discount? }) handles both VAT-net (default) and VAT-inclusive catalogue prices, plus per-line discount, and never produces a negative line.
  • cartTotal(items, { discount?, shipping?, shippingIncludesVat?, vatRate? }) applies the cart-level discount proportionally to net + VAT (the Saudi receipt convention, not a flat-line subtraction), adds shipping with VAT-on-top by default, and caps discounts at the gross subtotal so a 100% promo code does not produce a negative total.
  • formatLineItem(item, { format? }) renders every numeric field through formatRiyal, which means the same object drops straight into a receipt component, an OG card, or a JSON API response.

JavaScript Utilities

ts
import { formatRiyal, parseRiyal, maskRiyal, normalizeRiyalDigits, copyRiyal, addVAT, removeVAT, getVAT, SAUDI_VAT_RATE, convertFromSAR, convertToSAR, fetchExchangeRates, RIYAL_UNICODE, RIYAL_CODEPOINT, RIYAL_HTML_ENTITY, RIYAL_CSS_CONTENT, RIYAL_CURRENCY_CODE, RIYAL_ARABIC_ABBREVIATION, RIYAL_DEFAULT_LOCALE, RIYAL_RTL_LOCALE, } from "riyal";

Formatting

ts
formatRiyal(1234.5); // "ŝ 1,234.50" formatRiyal(1234.5, { locale: "ar-SA" }); // "١٬٢٣٤٫٥٠ ŝ" formatRiyal(100, { symbol: "ر.س", position: "suffix" }); // "100.00 ر.س" formatRiyal(1500000, { compact: true }); // "ŝ 1.5M" parseRiyal("ŝ 2,499.99"); // 2499.99 parseRiyal("١٬٢٣٤٫٥٠ ŝ"); // 1234.5 parseRiyal("1.2K"); // 1200

Saudi VAT

ts
SAUDI_VAT_RATE; // 0.15 addVAT(100); // 115 removeVAT(115); // 100 getVAT(100); // 15 addVAT(100, { rate: 0.05 }); // 105 — legacy 5% rate

Currency conversion (SAR base)

ts
import { fetchExchangeRates, convertFromSAR, convertToSAR } from "riyal"; const rates = await fetchExchangeRates(); // cached 1h in-memory await convertFromSAR(1000, "USD"); // SAR → USD await convertToSAR(100, "USD"); // USD → SAR await convertFromSAR(100, "USD", { rate: 0.27 }); // bypass network

Clipboard

ts
import { copyRiyal } from "riyal"; await copyRiyal(); // "⃁" await copyRiyal({ format: "html" }); // "&#x20C1;" await copyRiyal({ format: "css" }); // "\\20C1"

Error handling

fetchExchangeRates throws TypeError when the network is unavailable. convertFromSAR and convertToSAR throw RangeError when the target currency is missing from the rate table. In production, wrap them or use the useRiyalRate hook, which surfaces the error in its return value:

tsx
const { convert, loading, error } = useRiyalRate("EUR"); if (error) return <span>Rates unavailable</span>; if (loading) return <span>Loading…</span>; return <span>{convert(cartTotal)} EUR</span>;

Next.js Integration

tsx
// app/layout.tsx import { riyalFont } from "riyal/next"; export default function Layout({ children }: { children: React.ReactNode }) { return ( <html lang="en" className={riyalFont.variable}> <body>{children}</body> </html> ); }
css
/* app/globals.css */ @import "riyal/css"; :root { --font-riyal: var(--font-riyal-sans); }

Server vs Client Components

RiyalPrice, RiyalSymbol, and RiyalIcon have no client-side state. Use them directly in Server Components:

tsx
// app/product/page.tsx — Server Component, no directive needed import { RiyalPrice } from "riyal/react"; export default function ProductPage() { return <RiyalPrice amount={2499.99} />; }

AnimatedRiyalPrice and RiyalInput need requestAnimationFrame and React state, so they must be Client Components:

tsx
// components/cart-total.tsx "use client"; import { AnimatedRiyalPrice } from "riyal/react"; export function CartTotal({ total }: { total: number }) { return <AnimatedRiyalPrice amount={total} duration={400} />; }

Tailwind Plugin

Works with Tailwind v3 and v4:

ts
// tailwind.config.ts import riyal from "riyal/tailwind"; export default { plugins: [riyal()], };

For Tailwind v4, use the CSS-first config:

css
/* app.css */ @import "tailwindcss"; @plugin "riyal/tailwind";

The plugin adds:

ClassEffect
font-riyalfont-family: "Riyal", system-ui
font-riyal-arabicArabic variant
font-riyal-monoMonospace variant
riyal-symbol::before pseudo with U+20C1
riyal-price::before glyph + margin-inline-end: 0.25em
text-riyal-{50…900}Saudi green palette (#006c35 base)
riyal-{xs,sm,base,lg,xl,2xl}Symbol size utilities
html
<span class="font-riyal text-riyal-700">2,499.99</span> <span class="riyal-symbol text-riyal-500 riyal-lg"></span>

Font Variants

ImportVariantUse case
riyal/cssDefaultGeneral purpose
riyal/font/sans/woff2Sans-serifUI, fintech dashboards
riyal/font/serif/woff2SerifEditorial, long-form
riyal/font/mono/woff2MonospaceCode, terminal displays
riyal/font/arabic/woff2Arabicar-SA locale, RTL interfaces

The bundled font ships as WOFF2 (preferred), WOFF, and TTF, totalling roughly 58 kB packed including the JS surface.


React Native

tsx
import { RiyalSymbol, RiyalPrice } from "riyal/react-native"; <RiyalPrice amount={2499.99} style={{ fontSize: 24, color: "#000" }} />

Renders via react-native-svg, so no native font installation step is required on iOS or Android.


OG Image Cards

Two APIs. Pick one based on your runtime:

APIUse when
RiyalPriceCard(opts)You're using @vercel/og or next/og — returns a JSX element tree
generatePriceCardSVG(opts)Any backend or serverless function — returns an SVG string

With next/og:

tsx
// app/og/route.tsx import { ImageResponse } from "next/og"; import { RiyalPriceCard } from "riyal/og"; export const runtime = "edge"; export function GET() { return new ImageResponse( <RiyalPriceCard amount={2499.99} title="iPhone 16 Pro" locale="ar-SA" />, { width: 1200, height: 630 }, ); }

With any backend:

ts
import { generatePriceCardSVG } from "riyal/og"; const svg = generatePriceCardSVG({ amount: 2499.99, title: "Cart total", subtitle: "3 items", locale: "ar-SA", width: 1200, height: 630, background: "#006c35", color: "#ffffff", });

Both functions accept amount, title, subtitle, locale, width, height, background, color, and any FormatRiyalOptions.


CLI

Installed automatically as a riyal bin:

bash
riyal symbol # prints U+20C1 riyal copy # copies the glyph to clipboard riyal format 2499.99 # formatted SAR riyal vat add 100 # 115 riyal convert 100 USD # SAR → USD riyal --help

All Package Exports

ExportContents
riyalformatRiyal, parseRiyal, maskRiyal, copyRiyal, VAT helpers, conversion, constants
riyal/reactRiyalSymbol, RiyalIcon, RiyalPrice, AnimatedRiyalPrice, RiyalInput, useRiyalRate
riyal/vueSame surface, idiomatic Vue 3.4+ with defineComponent and composables
riyal/svelteSame surface, Svelte 5 runes-based components
riyal/react-nativeRiyalSymbol, RiyalIcon, RiyalPrice via react-native-svg
riyal/web-component<riyal-symbol>, <riyal-icon>, <riyal-price>, <riyal-animated-price>, <riyal-input>
riyal/cartlineItem, cartTotal, formatLineItem with 15% Saudi VAT defaults
riyal/nextriyalFont (Next.js next/font helper)
riyal/tailwindTailwind v3 + v4 plugin
riyal/ogRiyalPriceCard, generatePriceCardSVG
riyal/css, riyal/scssStylesheets with @font-face
riyal/font/{sans,serif,mono,arabic}/woff2Individual font variants

Browser Support

  • Modern evergreen browsers (Chrome, Edge, Firefox, Safari).
  • Safari ≥ 16, Chrome ≥ 110, Firefox ≥ 110 for Intl.NumberFormat with notation: "compact".
  • Node ≥ 20 for non-browser usage.

The Unicode Roadmap

riyal uses U+20C1 as the underlying codepoint everywhere. In React, Vue, Svelte, Web Components, CSS classes, and JS utilities. When operating systems ship native Unicode 17.0 font support, the migration is a single line:

diff
- import "riyal/css";

All components, all utilities, all Web Components continue working unchanged. The codepoint never changes. This is the same delete-one-line migration path that dirham users will follow when Unicode 18.0 fonts ship for AED.


Why riyal vs plain Intl.NumberFormat

Intl.NumberFormat formats numbers, but it does not know about U+20C1. You'd still need to append the symbol manually, handle RTL placement, build the 15% VAT helpers, write the cart math, source a glyph from somewhere, and wire it into a font face. riyal wraps all of that in one tree-shakable package.

CapabilityIntl.NumberFormatriyal
Web font (WOFF2/WOFF/TTF) for U+20C1
formatRiyal / parseRiyal (bidirectional)write it
Masked SAR input with paste cleanupwrite it
Saudi 15% VAT helperswrite it
Cart line-item + cart-total mathwrite it
SAR-based currency conversionwrite it
React, Vue, Svelte, React Native, Web Components
Tailwind plugin (v3 + v4)
Next.js next/font integration
OG share-image cards
TypeScript types, ESM + CJSpartial
shadcn registry

Resources


GitHub · npm · Docs

X / Twitter
LinkedIn
Facebook
WhatsApp
Telegram
AI Engineering for B2B

Stuck between an AI pilot and a system your team can run?

I join your engineering team and build the agent layer alongside you, covering architecture, MCP integration, evals, and production deployment. When the engagement ends, your team owns the system and keeps shipping.

12+ years shipping production systems

Senior engineer turned AI specialist. React, Next.js, AWS, agent orchestration.

Dubai-based, working with B2B teams worldwide

Direct collaboration across UAE, Europe, and US time zones.

AI agent teams that ship, not demos that stall

Discovery, role design, MCP integration, evals, and production deployment.

Questions about this piece

Follow-ups readers ask most often about the argument above.

  • riyal is an open source TypeScript toolkit by Pooya Golchian that ships the Saudi Riyal currency symbol (U+20C1) as a web font, React, Vue 3, Svelte 5, React Native, and Web Component primitives. Version 1.2.0 ships a masked SAR input, cart and checkout math with 15% Saudi VAT defaults, currency conversion, a Tailwind v3/v4 plugin, a Next.js font helper, OG cards, a CLI, and a shadcn-compatible registry. Install with: pnpm add riyal.

  • The Saudi Central Bank (SAMA) released the official Saudi Riyal glyph in February 2025. The codepoint U+20C1 is scheduled for Unicode 17.0 (September 2025), and operating systems will not render it natively for another full release cycle. riyal closes that gap with a SAMA-derived web font today, so Saudi fintech, e-commerce, and SaaS teams can ship the correct symbol now and remove a single import line once the OS fonts catch up. It mirrors the dirham package, which now does roughly 8,000 npm downloads per month using the same playbook.

  • Both packages share the same architecture (web font, React, Vue, Svelte, Web Components, Tailwind, Next.js, OG cards) but target different markets. dirham handles UAE Dirham at U+20C3 with a 5% VAT default. riyal handles Saudi Riyal at U+20C1 with a 15% VAT default, ar-SA RTL formatting, the SAMA-issued glyph, and a Saudi green color palette in the Tailwind plugin. dirham launched first and has reached ~8,000 monthly downloads. riyal v1.2.0 applies every lesson from that build.

  • Run pnpm add riyal, import the font helper in app/layout.tsx (import { riyalFont } from 'riyal/next'), and add @import 'riyal/css' to globals.css. Use RiyalPrice and RiyalSymbol directly in Server Components. Wrap AnimatedRiyalPrice and RiyalInput in 'use client' boundaries because they require requestAnimationFrame and React state.

  • riyal/cart ships lineItem, cartTotal, and formatLineItem. lineItem accepts unit price, quantity, vatIncluded flag, and per-line discount, with Saudi 15% VAT applied by default. cartTotal applies cart-level discounts proportionally to net + VAT (Saudi receipt convention), adds shipping with VAT-on-top, and returns subtotal, vat, shipping, netTotal, total, itemCount, and vatRate. formatLineItem renders every numeric field through formatRiyal for receipts and OG cards.

  • Yes. v1.2.0 ships first-class Vue 3.4+ and Svelte 5 entries with the same component surface as the React entry. Vue uses idiomatic v-model, defineComponent, and a useRiyalRate composable. Svelte ships .svelte source compiled by your bundler, with components built on Svelte 5 runes ($props, $state, $derived, $bindable). For Angular, Solid, or vanilla HTML, import riyal/web-component and call defineRiyalElements() to register <riyal-symbol>, <riyal-price>, <riyal-animated-price>, and <riyal-input>.

  • Yes. riyal ships a shadcn-compatible registry at riyal.js.org/r/registry.json. Pull production-ready components straight into components/riyal/ with the shadcn CLI. Three items are available today: riyal-price-tag (size and tone variants), riyal-amount-input (label, hint, error, masked editing), and riyal-checkout-summary (subtotal, VAT, shipping, grand total). The components install as source files you own, and pull riyal as the underlying npm dependency.

  • RiyalInput with the mask prop is a format-as-you-type SAR field. It accepts pasted strings like 'SAR 2,499.99', '⃁ 2,499.99', '99.90 ر.س', or Arabic-Indic digits ('٢٤٩٩٫٩٩'), normalises them to the underlying number 2499.99, and renders the locale-correct grouped string. Caret position is preserved across edits. The same mask and allowNegative props are available on the Vue and Svelte versions.

  • riyal defaults to the Saudi 15% rate (SAUDI_VAT_RATE = 0.15). The helpers addVAT, removeVAT, and getVAT all accept a custom rate as a second argument for legacy 5% data, GCC cross-border invoices, or zero-rated exports. The cart helpers (lineItem, cartTotal) take a vatRate option on the same path. formatRiyal(addVAT(1000)) returns the formatted gross including Saudi VAT.

  • No. riyal uses U+20C1 as the underlying codepoint everywhere — in React components, Vue components, Svelte components, Web Components, CSS classes, and JS utilities. When operating systems ship Unicode 17.0 font support, you delete one line: the riyal/css import. All components, all utilities, all Web Components continue working unchanged. The codepoint never changes.

Get practical AI and engineering playbooks

Weekly field notes on private AI, automation, and high-performance Next.js builds. Each edition is concise, implementation-ready, and tested in production work.

Open full subscription page

Get the latest insights on AI and full-stack development.