riyal

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.
5,427 3
Install
npm install riyal
Latest Version:1.2.1
Vue:3
License:MIT
Last Updated:May 8, 2026
Links: GitHub  ·  npm
Maintainer: pooya

riyal

npm version npm downloads license bundle

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.


Table of contents


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-SA and ar-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/font integration.
  • 🖼️ 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 riyal

Peer 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.json

Each 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"); // 1200

VAT 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.15

Cart & 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 through formatRiyal for 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 network

Clipboard

import { copyRiyal } from "riyal";

await copyRiyal(); // "\u20C1"
await copyRiyal({ format: "html" }); // "&#x20C1;"
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-dom
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).

<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/woff2
  • riyal/font/woff
  • riyal/font/ttf
  • riyal/font/sans/woff2
  • riyal/font/serif/woff2
  • riyal/font/mono/woff2
  • riyal/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 --help

Constants & locales

import {
  RIYAL_UNICODE, // "\u20C1"
  RIYAL_CODEPOINT, // 0x20C1
  RIYAL_HTML_ENTITY, // "&#x20C1;"
  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.NumberFormat with notation: "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   # biome

Releases ship via Changesets:

pnpm changeset

License

MIT © Pooya Golchian

The Saudi Riyal symbol glyph is based on the SAMA (Saudi Central Bank) design released in February 2025 and mapped to U+20C1.

Related Packages

storybook

Storybook: Develop, document, and test UI components in isolation

758,956,709 91,335
@mushi-mushi/cli

Mushi CLI: SDK setup, MCP wiring, bug triage. The bug mediator for AI-built apps...

10,380 3
@ohos-ports/storybook

Storybook: Develop, document, and test UI components in isolation

965 91,369
create-mushi-mushi

Run `npm create mushi-mushi` to add the Mushi Mushi bug-reporting SDK to your ex...

5,326 3

Version History

Version Released License
1.2.1 MIT
1.2.0 MIT
1.1.0 MIT
1.0.2 MIT
1.0.1 MIT