Back to Blog

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

ReactVueSvelteTypeScriptOpen SourceUnicodeCurrencySaudi ArabiaKSAnpmFintechriyalSARU+20C1Web ComponentsNext.jsshadcnTailwind
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

About Pooya Golchian

Common questions about Pooya's work, AI services, and how to start a project together.

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.