riyal
v1.2.1
Published
The Saudi Riyal currency symbol (U+20C1) as a web font, CSS utility, React, Vue 3, Svelte 5, React Native, and Web Component, with a Tailwind plugin, Next.js font helper, OG cards, VAT helpers, and currency conversion.
Maintainers
Keywords
Readme
riyal
The Saudi Riyal currency symbol (U+20C1) toolkit — a web font, CSS, React, Vue 3, Svelte 5, React Native, Web Components, a Tailwind plugin, Next.js font helpers, OG image cards, and a CLI. Written in TypeScript, ships ESM + CJS + type defs.
Built around U+20C1 (Saudi Riyal Sign) — the codepoint scheduled for Unicode 17.0 (September 2025). Until OS fonts ship native support, this package renders the symbol via a bundled web font derived from the SAMA (Saudi Central Bank) glyph released in February 2025. Once Unicode 17.0 lands, the font becomes optional with no API changes.
- 🌐 Live demo: riyal.js.org
- 📦 npm: npmjs.com/package/riyal
- 🐙 GitHub: pooyagolchian/riyal
Table of contents
- Features
- Installation — including the shadcn registry
- Quick start
- Core API — formatting, parsing, VAT, conversion, clipboard, error handling
- Cart & checkout primitives —
riyal/cart—lineItem,cartTotal,formatLineItem - React —
RiyalSymbol,RiyalIcon,RiyalPrice,AnimatedRiyalPrice,RiyalInput(with masked mode),useRiyalRate - Vue 3 — same surface, idiomatic Vue
- Svelte 5 — same surface, runes-based components
- Web Components — attribute reference, events, shadow DOM styling
- React Native
- CSS / SCSS
- Tailwind plugin
- Next.js font helper — Server vs Client Components
- OG image cards
- CLI
- Constants & locales
- Browser support
- Why riyal?
- Contributing
- License
Features
- ⚡ Tiny — tree-shakable ESM, ~58 kB packed (font included).
- 🧮 Masked currency input — paste
"SAR 2,499.99"," 2,499.99", or"٢٤٩٩٫٩٩"and get a clean number plus a perfectly formatted display. - 🎨 Multiple weights & families — sans, serif, mono, arabic.
- 🧮 VAT helpers — Saudi 15% default, configurable.
- 💱 Currency conversion — SAR-based, in-memory cached.
- 🔤
Intl.NumberFormat—en-SAandar-SA(RTL) out of the box. - ⚛️ React, Vue 3, Svelte 5, React Native, and Web Components — pick your stack.
- 🎯 Tailwind v3 & v4 plugin, Next.js
next/fontintegration. - 🖼️ OG cards for share images.
- 🛠️ CLI for quick lookups & copy-to-clipboard.
Framework matrix
| Stack | Entry | Components |
| --- | --- | --- |
| React 18+ | riyal/react | RiyalSymbol, RiyalIcon, RiyalPrice, AnimatedRiyalPrice, RiyalInput, useRiyalRate |
| Vue 3.4+ | riyal/vue | Same surface as React, idiomatic Vue with defineComponent + composables |
| Svelte 5 | riyal/svelte | Same surface as React, native .svelte components using runes |
| React Native 0.72+ | riyal/react-native | RiyalSymbol, RiyalIcon, RiyalPrice |
| Vanilla / Angular / Solid / others | riyal/web-component | <riyal-symbol>, <riyal-icon>, <riyal-price>, <riyal-animated-price>, <riyal-input> |
Installation
# pnpm
pnpm add riyal
# npm
npm install riyal
# yarn
yarn add riyal
# bun
bun add riyalPeer deps (all optional, only required for the entry you import):
| Entry | Peer |
| --- | --- |
| riyal/react | react ≥ 18, react-dom ≥ 18 |
| riyal/vue | vue ≥ 3.4 |
| riyal/svelte | svelte ≥ 5 |
| riyal/react-native | react-native ≥ 0.72, react-native-svg ≥ 13 |
| riyal/tailwind | tailwindcss ≥ 3 |
| riyal/next | next ≥ 13 |
Node ≥ 20 is required (for full ICU / Intl support).
Add via shadcn
Riyal ships a shadcn-compatible registry so you can pull production-ready components straight into your project with the shadcn CLI:
# 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/ in your project so you
own the source — and pulls riyal 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)
addVAT(100); // 115 (15% Saudi VAT)
RIYAL_UNICODE; // "ŝ" (U+20C1)Core API
Imported from the root entry: import { ... } from "riyal";
formatRiyal(amount, options?)
Format a number as a Riyal-prefixed/suffixed string using
Intl.NumberFormat.
import { formatRiyal } from "riyal";
formatRiyal(1234.5);
formatRiyal(1234.5, {
locale: "ar-SA",
decimals: 0,
symbol: "ر.س", // override the glyph
position: "suffix", // "prefix" | "suffix"
compact: true, // 1.2K-style notation
});FormatRiyalOptions:
| Option | Type | Default |
| ------------------------------------- | ---------------------- | ---------------- |
| locale | string | "en-SA" |
| decimals | number | 2 |
| symbol | string | "\u20C1" |
| position | "prefix" \| "suffix" | locale-dependent |
| compact | boolean | false |
| groupSeparator / decimalSeparator | string | locale defaults |
parseRiyal(input)
Strict reverse of formatRiyal — supports compact (1.2K), grouped, and
Arabic-Indic digits.
parseRiyal("ŝ 2,499.99"); // 2499.99
parseRiyal("١٬٢٣٤٫٥٠ ŝ"); // 1234.5
parseRiyal("1.2K"); // 1200VAT helpers
import { addVAT, removeVAT, getVAT, SAUDI_VAT_RATE } from "riyal";
addVAT(100); // 115
removeVAT(115); // 100
getVAT(100); // 15
addVAT(100, { rate: 0.05 }); // 105
SAUDI_VAT_RATE; // 0.15Cart & checkout primitives — riyal/cart
Receipt-grade math for line items and cart totals, with Saudi-VAT defaults.
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"Highlights:
lineItem({ unit, qty, vatIncluded?, discount? }, { vatRate? })— 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 (Saudi receipt convention), adds shipping with VAT-on-top by default, and caps discounts at the gross subtotal.formatLineItem(item, { format? })— renders every numeric field throughformatRiyalfor receipts, OG cards, and table rendering.
Currency 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(); // "\u20C1"
await copyRiyal({ format: "html" }); // "⃁"
await copyRiyal({ format: "css" }); // "\\20C1"Error handling
fetchExchangeRates throws a TypeError when the network is unavailable.
convertFromSAR / convertToSAR throw a RangeError when the target currency
is not in the rate table. Always wrap in try/catch in production:
import { convertFromSAR } from "riyal";
let usd: number;
try {
usd = await convertFromSAR(1000, "USD");
} catch {
usd = 1000 * 0.267; // last-known SAR/USD fallback
}useRiyalRate surfaces the error in its return value — no extra try/catch needed:
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>;React
pnpm add riyal react react-domimport "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).
<RiyalIcon width={32} height={32} aria-label="SAR" /><RiyalPrice />
Formatted price, locale-aware.
<RiyalPrice amount={2499.99} />
<RiyalPrice amount={2499.99} locale="ar-SA" decimals={0} />
<RiyalPrice amount={1_200_000} compact /><AnimatedRiyalPrice />
Spring-animated counter for live totals.
<AnimatedRiyalPrice amount={cartTotal} duration={400} /><RiyalInput />
Controlled numeric input that displays formatRiyal while preserving the
underlying 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");
return <span>{convert(2499.99)} USD</span>;Vue 3
pnpm add riyal 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; works with Nuxt out of the box.
RiyalInput uses v-model (binds to modelValue) and emits both
update:modelValue and change.
Svelte 5
pnpm add riyal 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>, and
<riyal-price>.
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>Attribute reference
| Element | Attribute | Type | Default | Reactive |
| --- | --- | --- | --- | --- |
| <riyal-symbol> | size | CSS length | 1em | yes |
| <riyal-icon> | width / height | number (px) | 24 | yes |
| <riyal-icon> | aria-label | string | "Saudi Riyal" | yes |
| <riyal-price> | amount | number string | required | yes |
| <riyal-price> | locale | "en-SA" | "ar-SA" | "en-SA" | yes |
| <riyal-price> | decimals | number | 2 | yes |
| <riyal-price> | compact | boolean attribute | false | yes |
| <riyal-animated-price> | amount | number string | required | yes |
| <riyal-animated-price> | duration | number (ms) | 600 | yes |
| <riyal-input> | value | number string | "" | yes |
All attributes are observed — setting them via setAttribute or a framework
binding triggers a re-render with no extra boilerplate.
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;
}React Native
import { RiyalSymbol, RiyalPrice } from "riyal/react-native";
// Renders via react-native-svg — no font installation required.
<RiyalPrice amount={2499.99} />;CSS / SCSS
Self-host the font + utility classes:
/* CSS */
@import "riyal/css";// SCSS
@use "riyal/scss" as riyal;Or pull a single weight:
@font-face {
font-family: "Riyal";
src: url("riyal/font/woff2") format("woff2");
}Available font subpaths:
riyal/font/woff2riyal/font/woffriyal/font/ttfriyal/font/sans/woff2riyal/font/serif/woff2riyal/font/mono/woff2riyal/font/arabic/woff2
Tailwind plugin
Works with Tailwind v3 and v4.
// tailwind.config.ts
import riyal from "riyal/tailwind";
export default {
plugins: [riyal()],
};Adds these utilities:
| Class | Effect |
| --- | --- |
| font-riyal | font-family: "Riyal", system-ui |
| font-riyal-arabic | Arabic variant of the Riyal font |
| font-riyal-mono | Monospace variant |
| riyal-symbol | ::before with U+20C1 glyph |
| 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>Tailwind v4 — use the CSS-first config:
/* app.css */
@import "tailwindcss";
@plugin "riyal/tailwind";Next.js font helper
// 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>
);
}/* globals.css */
:root {
--font-riyal: var(--font-riyal-sans);
}Server vs Client Components (Next.js App Router)
RiyalPrice and RiyalSymbol have no client-side state — use them directly in
Server Components. AnimatedRiyalPrice and RiyalInput require
requestAnimationFrame / React state, so they must be Client Components:
// app/product/page.tsx — Server Component, no directive needed
import { RiyalPrice } from "riyal/react";
export default function ProductPage() {
return <RiyalPrice amount={2499.99} />;
}// components/cart-total.tsx — must be a Client Component
"use client";
import { AnimatedRiyalPrice } from "riyal/react";
export function CartTotal({ total }: { total: number }) {
return <AnimatedRiyalPrice amount={total} duration={400} />;
}OG image cards
Generate Open Graph share images on the fly. Two APIs — pick one:
| API | Use when |
| --- | --- |
| RiyalPriceCard(opts) | You're using @vercel/og or next/og — returns a JSX element tree |
| generatePriceCardSVG(opts) | Any backend / serverless function — returns an SVG string, no JSX runtime needed |
With @vercel/og or Next.js App Router:
// 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 (returns SVG string):
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",
});
// → <svg xmlns="http://www.w3.org/2000/svg" ...>…</svg>Both functions accept the same options: amount, title, subtitle,
locale, width, height, background, color, and all 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 --helpConstants & locales
import {
RIYAL_UNICODE, // "\u20C1"
RIYAL_CODEPOINT, // 0x20C1
RIYAL_HTML_ENTITY, // "⃁"
RIYAL_CSS_CONTENT, // "\\20C1"
RIYAL_CURRENCY_CODE, // "SAR"
RIYAL_ARABIC_ABBREVIATION, // "ر.س"
RIYAL_DEFAULT_LOCALE, // "en-SA"
RIYAL_RTL_LOCALE, // "ar-SA"
} from "riyal";Browser support
- Modern evergreen browsers (Chrome, Edge, Firefox, Safari).
- Safari ≥ 16, Chrome ≥ 110, Firefox ≥ 110 (uses
Intl.NumberFormatwithnotation: "compact"). - Node ≥ 20 for non-browser usage.
The bundled font ships as WOFF2 (preferred), WOFF, and TTF.
Why riyal?
vs plain Intl.NumberFormat
Intl.NumberFormat formats numbers but does not know about U+20C1 — you'd
still need to append the symbol manually, handle RTL placement, and build VAT
and conversion helpers yourself. riyal wraps all of that in one package.
What riyal includes
| Feature | riyal |
| --- | --- |
| Web font (WOFF2/WOFF/TTF) | yes |
| U+20C1 + U+E900 (legacy) | yes |
| formatRiyal / parseRiyal | yes |
| VAT helpers | yes |
| Currency conversion | yes |
| React components | yes |
| Web Components | yes |
| TypeScript types | yes |
| CDN / no-build usage | via jsDelivr |
Contributing
PRs welcome. See CONTRIBUTING.md and the Code of Conduct.
pnpm install
pnpm --filter riyal dev # watch builds
pnpm test # vitest
pnpm lint && pnpm format # biomeReleases ship via Changesets:
pnpm changesetLicense
The Saudi Riyal symbol glyph is based on the SAMA (Saudi Central Bank) design released in February 2025 and mapped to U+20C1.
