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.
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:
- How do I format an amount in
ar-AEwith the symbol in the right place? - How do I add 5% VAT to a cart total?
- 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
pnpm add riyal
# or
npm install riyal
# or
yarn add riyal
# or
bun add riyalNode ≥ 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:
# 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.jsonEach 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
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
import "riyal/css";
import {
RiyalSymbol,
RiyalIcon,
RiyalPrice,
AnimatedRiyalPrice,
RiyalInput,
useRiyalRate,
} from "riyal/react";<RiyalSymbol />
Inline span using the bundled font, sized via CSS em:
<RiyalSymbol size={24} />
<RiyalSymbol size="1.25em" weight={600} /><RiyalIcon />
Standalone SVG icon. No font required, fully tree-shakeable, SSR-safe:
<RiyalIcon width={32} height={32} aria-label="SAR" /><RiyalPrice />
Locale-aware formatted price:
<RiyalPrice amount={2499.99} />
<RiyalPrice amount={2499.99} locale="ar-SA" decimals={0} />
<RiyalPrice amount={1_200_000} compact />| Prop | Default | Description |
|---|---|---|
amount | 0 | Numeric value |
locale | "en-SA" | Intl locale ("en-SA" or "ar-SA") |
decimals | 2 | Decimal places |
compact | false | Compact notation (1.2K, 1.5M) |
position | locale-set | "prefix" or "suffix" |
weight | "regular" | Symbol stroke weight |
<AnimatedRiyalPrice />
Spring-animated counter for live cart totals:
<AnimatedRiyalPrice amount={cartTotal} duration={400} /><RiyalInput />
Controlled numeric input that displays formatRiyal while preserving the raw number:
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:
<RiyalInput mask value={value} onValueChange={setValue} />| User does | Input shows | onValueChange 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:
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:
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
<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
<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>:
import { defineRiyalElements } from "riyal/web-component";
import "riyal/css";
defineRiyalElements();<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
| Element | Attribute | Type | Default |
|---|---|---|---|
<riyal-symbol> | size | CSS length | 1em |
<riyal-icon> | width / height | number (px) | 24 |
<riyal-icon> | aria-label | string | "Saudi Riyal" |
<riyal-price> | amount | number string | required |
<riyal-price> | locale | en-SA | ar-SA | "en-SA" |
<riyal-price> | decimals | number | 2 |
<riyal-price> | compact | boolean | false |
<riyal-animated-price> | amount | number string | required |
<riyal-animated-price> | duration | number (ms) | 600 |
<riyal-input> | value | number string | "" |
Events
<riyal-input> dispatches a riyal-change CustomEvent when the value changes:
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:
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:
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 throughformatRiyal, which means the same object drops straight into a receipt component, an OG card, or a JSON API response.
JavaScript Utilities
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
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"); // 1200Saudi VAT
SAUDI_VAT_RATE; // 0.15
addVAT(100); // 115
removeVAT(115); // 100
getVAT(100); // 15
addVAT(100, { rate: 0.05 }); // 105 — legacy 5% rateCurrency conversion (SAR base)
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 networkClipboard
import { copyRiyal } from "riyal";
await copyRiyal(); // ""
await copyRiyal({ format: "html" }); // "⃁"
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:
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
// 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>
);
}/* 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:
// 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:
// 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:
// tailwind.config.ts
import riyal from "riyal/tailwind";
export default {
plugins: [riyal()],
};For Tailwind v4, use the CSS-first config:
/* app.css */
@import "tailwindcss";
@plugin "riyal/tailwind";The plugin adds:
| Class | Effect |
|---|---|
font-riyal | font-family: "Riyal", system-ui |
font-riyal-arabic | Arabic variant |
font-riyal-mono | Monospace 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 |
<span class="font-riyal text-riyal-700">2,499.99</span>
<span class="riyal-symbol text-riyal-500 riyal-lg"></span>Font Variants
| Import | Variant | Use case |
|---|---|---|
riyal/css | Default | General purpose |
riyal/font/sans/woff2 | Sans-serif | UI, fintech dashboards |
riyal/font/serif/woff2 | Serif | Editorial, long-form |
riyal/font/mono/woff2 | Monospace | Code, terminal displays |
riyal/font/arabic/woff2 | Arabic | ar-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
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:
| API | Use 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:
// 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:
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:
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 --helpAll Package Exports
| Export | Contents |
|---|---|
riyal | formatRiyal, parseRiyal, maskRiyal, copyRiyal, VAT helpers, conversion, constants |
riyal/react | RiyalSymbol, RiyalIcon, RiyalPrice, AnimatedRiyalPrice, RiyalInput, useRiyalRate |
riyal/vue | Same surface, idiomatic Vue 3.4+ with defineComponent and composables |
riyal/svelte | Same surface, Svelte 5 runes-based components |
riyal/react-native | RiyalSymbol, RiyalIcon, RiyalPrice via react-native-svg |
riyal/web-component | <riyal-symbol>, <riyal-icon>, <riyal-price>, <riyal-animated-price>, <riyal-input> |
riyal/cart | lineItem, cartTotal, formatLineItem with 15% Saudi VAT defaults |
riyal/next | riyalFont (Next.js next/font helper) |
riyal/tailwind | Tailwind v3 + v4 plugin |
riyal/og | RiyalPriceCard, generatePriceCardSVG |
riyal/css, riyal/scss | Stylesheets with @font-face |
riyal/font/{sans,serif,mono,arabic}/woff2 | Individual font variants |
Browser Support
- Modern evergreen browsers (Chrome, Edge, Firefox, Safari).
- Safari ≥ 16, Chrome ≥ 110, Firefox ≥ 110 for
Intl.NumberFormatwithnotation: "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:
- 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.
| Capability | Intl.NumberFormat | riyal |
|---|---|---|
| Web font (WOFF2/WOFF/TTF) for U+20C1 | — | ✓ |
formatRiyal / parseRiyal (bidirectional) | write it | ✓ |
| Masked SAR input with paste cleanup | write it | ✓ |
| Saudi 15% VAT helpers | write it | ✓ |
| Cart line-item + cart-total math | write it | ✓ |
| SAR-based currency conversion | write it | ✓ |
| React, Vue, Svelte, React Native, Web Components | — | ✓ |
| Tailwind plugin (v3 + v4) | — | ✓ |
Next.js next/font integration | — | ✓ |
| OG share-image cards | — | ✓ |
| TypeScript types, ESM + CJS | partial | ✓ |
| shadcn registry | — | ✓ |
Resources
- npm package
- GitHub repository
- Documentation
- Sister project: dirham (UAE)
- Unicode 17.0 Character Charts
- Saudi Central Bank (SAMA) — Currency
