Icons
Lucide is the only icon library. How to size, colour, label and mirror icons in Thanawi products.
Usage
Every icon comes from Lucide through the lucide-react package. Browse the full set at lucide.dev/icons and import icons by name:
import { ArrowRight } from 'lucide-react';
<Button endIcon={<ArrowRight className="rtl:-scale-x-100" />}>Continue</Button>;- Lucide only. No other icon library, and no hand-drawn
<svg>icons. The Thanawi logo is the only exception. - One version.
@thanawi/uiand the docs pin the samelucide-reactrange, so an icon looks the same everywhere. - Enforced.
pnpm --filter docs lintfails on any other icon package or Lucide package, on deeplucide-react/…paths, on.svgimports and on inline<svg>.
Sizes
Components size their own icons with [&_svg]:size-*. Pass the icon without a size and it matches the component automatically:
| Icon size | Utility | Used in |
|---|---|---|
| 12px | size-3 | Badge sm |
| 14px | size-3.5 | Badge md, Input error message |
| 16px | size-4 | Button and Input sm / md, icon buttons icon-sm / icon |
| 20px | size-5 | Button and Input lg, icon button icon-lg, Alert icon |
size-3Badge smsize-3.5Badge md · field errorsize-4Button / Input sm · mdsize-5Button / Input lg · Alertimport { ArrowRight, Check, Plus, Search } from 'lucide-react';
import { Alert, AlertTitle, Badge, Button, Input } from '@thanawi/ui';
const copy = {
en: { verified: 'Verified', add: 'Add funds', next: 'Continue', search: 'Search', limit: 'Limit almost reached' },
ar: { verified: 'موثّق', add: 'إضافة رصيد', next: 'متابعة', search: 'بحث', limit: 'اقتربت من الحد' },
} as const;
/** Icons take their size from the component: never set it on the icon itself. */
export default function IconSizes({ locale = 'en' }: { locale?: 'en' | 'ar' }) {
const t = copy[locale];
return (
<div className="flex w-full max-w-xl flex-col divide-y divide-border-subtle">
<SizeRow size="12px" utility="size-3" where="Badge sm">
<Badge size="sm" variant="positive">
<Check />
{t.verified}
</Badge>
</SizeRow>
<SizeRow size="14px" utility="size-3.5" where="Badge md · field error">
<Badge variant="positive">
<Check />
{t.verified}
</Badge>
</SizeRow>
<SizeRow size="16px" utility="size-4" where="Button / Input sm · md">
<Button size="sm" variant="outline" startIcon={<Plus />}>
{t.add}
</Button>
<Button endIcon={<ArrowRight className="rtl:-scale-x-100" />}>{t.next}</Button>
<Input aria-label={t.search} placeholder={t.search} startAdornment={<Search />} className="w-44" />
</SizeRow>
<SizeRow size="20px" utility="size-5" where="Button / Input lg · Alert">
<Button size="lg" endIcon={<ArrowRight className="rtl:-scale-x-100" />}>
{t.next}
</Button>
<Alert variant="warning" className="w-auto py-2.5">
<AlertTitle>{t.limit}</AlertTitle>
</Alert>
</SizeRow>
</div>
);
}
function SizeRow({ size, utility, where, children }: { size: string; utility: string; where: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-3 py-4 first:pt-0 last:pb-0 sm:flex-row sm:items-center sm:gap-6">
<div dir="ltr" className="flex w-40 shrink-0 flex-col text-start">
<span className="text-sm font-semibold text-content-default">
{size} · <code>{utility}</code>
</span>
<span className="text-xs text-content-muted">{where}</span>
</div>
<div className="flex flex-wrap items-center gap-3">{children}</div>
</div>
);
}Outside a component, give the icon a size class that matches its text:
| Text | Icon |
|---|---|
text-xs | size-3.5 (14px) |
text-sm | size-4 (16px) |
text-base | size-5 (20px) |
Stroke
Keep Lucide’s default stroke: strokeWidth={2} on its 24px grid. The stroke scales with the icon, so it stays in proportion: about 1.33px at 16px and 1.67px at 20px. That keeps icons close to the weight of the Cairo text beside them at every size.
Don’t pass strokeWidth or absoluteStrokeWidth. A fixed 2px stroke makes small icons heavy and fills in their detail, as the second row shows.
import { Landmark } from 'lucide-react';
const sizes = [12, 14, 16, 20, 24] as const;
const copy = {
en: { default: 'Default — strokeWidth 2 on a 24px grid, scales with the icon', absolute: 'absoluteStrokeWidth — avoid: small icons get heavy' },
ar: { default: 'الافتراضي — سُمك 2 على شبكة 24px، يتناسب مع حجم الأيقونة', absolute: 'absoluteStrokeWidth — تجنّبه: تثقل الأيقونات الصغيرة' },
} as const;
/** Keep Lucide's default stroke: it stays proportional to the icon, like the text next to it. */
export default function IconStroke({ locale = 'en' }: { locale?: 'en' | 'ar' }) {
const t = copy[locale];
return (
<div className="flex w-full max-w-xl flex-col gap-8">
<StrokeRow label={t.default} />
<StrokeRow label={t.absolute} absolute />
</div>
);
}
function StrokeRow({ label, absolute = false }: { label: string; absolute?: boolean }) {
return (
<figure className="flex flex-col gap-3">
<figcaption className={absolute ? 'text-sm text-content-negative' : 'text-sm text-content-default'}>{label}</figcaption>
<div className="flex items-end gap-6 text-content-secondary">
{sizes.map((size) => (
<div key={size} className="flex flex-col items-center gap-2">
<Landmark size={size} absoluteStrokeWidth={absolute} />
<span dir="ltr" className="text-xs text-content-muted tabular-nums">
{size}px · {absolute ? '2' : +((2 * size) / 24).toFixed(2)}px
</span>
</div>
))}
</div>
</figure>
);
}Colour
Lucide icons draw with currentColor. Set the colour on the text with a semantic token and the icon follows. Never hard-code a color, fill or stroke.
text-content-defaulttext-content-positivetext-content-warningtext-content-negativetext-content-infotext-content-on-primaryimport { CircleAlert, CircleCheck, Info, Send, TriangleAlert, Wallet } from 'lucide-react';
import { Button } from '@thanawi/ui';
const copy = {
en: { balance: 'Balance', received: 'Funds received', pending: 'Review pending', failed: 'Payment failed', note: 'New statement', send: 'Send' },
ar: { balance: 'الرصيد', received: 'تم استلام المبلغ', pending: 'بانتظار المراجعة', failed: 'فشل الدفع', note: 'كشف حساب جديد', send: 'إرسال' },
} as const;
/** Icons are `currentColor`: colour the text with a semantic token and the icon follows. */
export default function IconColor({ locale = 'en' }: { locale?: 'en' | 'ar' }) {
const t = copy[locale];
const rows = [
{ Icon: Wallet, label: t.balance, className: 'text-content-default', token: 'text-content-default' },
{ Icon: CircleCheck, label: t.received, className: 'text-content-positive', token: 'text-content-positive' },
{ Icon: TriangleAlert, label: t.pending, className: 'text-content-warning', token: 'text-content-warning' },
{ Icon: CircleAlert, label: t.failed, className: 'text-content-negative', token: 'text-content-negative' },
{ Icon: Info, label: t.note, className: 'text-content-info', token: 'text-content-info' },
];
return (
<div className="flex w-full max-w-md flex-col gap-3">
{rows.map(({ Icon, label, className, token }) => (
<div key={token} className="flex items-center justify-between gap-4">
<span className={`inline-flex items-center gap-2 text-sm font-medium ${className}`}>
<Icon className="size-4" />
{label}
</span>
<code dir="ltr" className="text-xs text-content-muted">
{token}
</code>
</div>
))}
<div className="flex items-center justify-between gap-4 border-t border-border-subtle pt-3">
<Button size="sm" startIcon={<Send className="rtl:-scale-x-100" />}>
{t.send}
</Button>
<code dir="ltr" className="text-xs text-content-muted">
text-content-on-primary
</code>
</div>
</div>
);
}- Use the status tokens (
text-content-positive,-warning,-negative,-info) only when the icon carries that status. - On filled surfaces, the component already sets the matching
content-on-*colour.
Accessibility
- Decorative icons sit next to visible text: in
startIcon/endIcon, input adornments, badges. Write nothing.lucide-reactrendersaria-hidden="true"on any icon without anaria-*,roleortitleprop. Only the icons a component draws itself (the loading spinner, the dismiss ×, the field-error icon and the alert status icon) setaria-hidden="true"explicitly. - Icon-only controls: label the control, not the icon. Put a localised
aria-labelon the button; the icon stays decorative. - Meaningful standalone icons carry information and have no visible text. Give them
role="img"and a localisedaria-label. Prefer visible text when there’s room. - Never rely on an icon alone to convey status. Pair it with text, as in Colors › Status colors.
<Button startIcon={<Download />}>Download statement</Button>
<Button size="icon" variant="outline" aria-label="تحديث الرصيد">
<RefreshCw />
</Button>
<BadgeCheck role="img" aria-label="Verified" className="size-5 text-content-positive" />import { BadgeCheck, Download, RefreshCw } from 'lucide-react';
import { Button } from '@thanawi/ui';
const copy = {
en: { download: 'Download statement', refresh: 'Refresh balance', verified: 'Verified account', decorative: 'Decorative: write nothing', iconOnly: 'Icon-only: label the control', meaningful: 'Meaningful on its own: label the icon' },
ar: { download: 'تنزيل كشف الحساب', refresh: 'تحديث الرصيد', verified: 'حساب موثّق', decorative: 'زخرفية: لا تكتب شيئًا', iconOnly: 'أيقونة فقط: سمِّ عنصر التحكم', meaningful: 'ذات معنى بمفردها: سمِّ الأيقونة' },
} as const;
export default function IconAccessibility({ locale = 'en' }: { locale?: 'en' | 'ar' }) {
const t = copy[locale];
return (
<div className="grid w-full max-w-2xl gap-6 sm:grid-cols-3">
<Case caption={t.decorative}>
{/* Visible text says it all; lucide-react renders aria-hidden="true" by itself. */}
<Button variant="outline" startIcon={<Download />}>
{t.download}
</Button>
</Case>
<Case caption={t.iconOnly}>
{/* The control carries the localised name; the icon stays decorative. */}
<Button size="icon" variant="outline" aria-label={t.refresh}>
<RefreshCw />
</Button>
</Case>
<Case caption={t.meaningful}>
<BadgeCheck role="img" aria-label={t.verified} className="size-5 text-content-positive" />
</Case>
</div>
);
}
function Case({ caption, children }: { caption: string; children: React.ReactNode }) {
return (
<figure className="flex flex-col items-center gap-3 text-center">
<div className="flex h-12 items-center">{children}</div>
<figcaption className="text-xs text-content-muted">{caption}</figcaption>
</figure>
);
}RTL flipping
Icons that point along the reading direction flip in right-to-left layouts; everything else stays as drawn. Put rtl:-scale-x-100 on the icon itself. It follows the nearest dir. Icon names are as on lucide.dev/icons. Use the EN / AR toggle on the preview:
Flip with rtl:-scale-x-100 | Never flip |
|---|---|
Horizontal and diagonal arrows: arrow-left / -right, arrow-up-right | Vertical: arrow-up / -down, chevron-down, download, upload |
Left/right chevrons: chevron-left / -right, chevrons-left / -right | Symmetric: plus, x, check, info, circle-alert, triangle-alert |
move-left / -right, send, trending-up / -down | Rotation, not direction: the spinner, refresh-cw, clock |
undo / redo, reply, forward | Real-world objects: search, file-text |
log-in / log-out, external-link | Brand marks, including the Thanawi logo |
Flip in RTL
Never flip
import {
ArrowDown,
ArrowRight,
ArrowUpRight,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
Clock,
Download,
ExternalLink,
FileText,
Info,
LogIn,
MoveRight,
Plus,
RefreshCw,
Reply,
Search,
Send,
TrendingUp,
Undo,
X,
} from 'lucide-react';
import { Button } from '@thanawi/ui';
import { LogoMark } from '@/components/logo';
const copy = {
en: { flip: 'Flip in RTL', keep: 'Never flip', back: 'Back', next: 'Continue', eta: 'Arrives in 2 min', brand: 'brand' },
ar: { flip: 'تنعكس في RTL', keep: 'لا تنعكس أبدًا', back: 'رجوع', next: 'متابعة', eta: 'يصل خلال دقيقتين', brand: 'الشعار' },
} as const;
// They point along the reading direction: add rtl:-scale-x-100 on the icon itself.
const flip = [
{ name: 'arrow-right', Icon: ArrowRight },
{ name: 'arrow-up-right', Icon: ArrowUpRight },
{ name: 'chevron-right', Icon: ChevronRight },
{ name: 'move-right', Icon: MoveRight },
{ name: 'send', Icon: Send },
{ name: 'trending-up', Icon: TrendingUp },
{ name: 'undo', Icon: Undo },
{ name: 'reply', Icon: Reply },
{ name: 'log-in', Icon: LogIn },
{ name: 'external-link', Icon: ExternalLink },
];
// Vertical, symmetric, rotating or real-world: they read the same in both directions.
const keep = [
{ name: 'arrow-down', Icon: ArrowDown },
{ name: 'chevron-down', Icon: ChevronDown },
{ name: 'download', Icon: Download },
{ name: 'plus', Icon: Plus },
{ name: 'x', Icon: X },
{ name: 'check', Icon: Check },
{ name: 'info', Icon: Info },
{ name: 'refresh-cw', Icon: RefreshCw },
{ name: 'clock', Icon: Clock },
{ name: 'search', Icon: Search },
{ name: 'file-text', Icon: FileText },
];
export default function IconRtl({ locale = 'en' }: { locale?: 'en' | 'ar' }) {
const t = copy[locale];
return (
<div className="flex w-full max-w-2xl flex-col gap-8">
<div className="flex flex-wrap items-center gap-3">
<Button variant="ghost" startIcon={<ChevronLeft className="rtl:-scale-x-100" />}>
{t.back}
</Button>
<Button endIcon={<ArrowRight className="rtl:-scale-x-100" />}>{t.next}</Button>
<span className="inline-flex items-center gap-1.5 text-sm text-content-muted">
<Clock className="size-4" />
{t.eta}
</span>
</div>
<IconSet title={t.flip}>
{flip.map(({ name, Icon }) => (
<Tile key={name} name={name}>
<Icon className="size-5 rtl:-scale-x-100" />
</Tile>
))}
</IconSet>
<IconSet title={t.keep}>
{keep.map(({ name, Icon }) => (
<Tile key={name} name={name}>
<Icon className="size-5" />
</Tile>
))}
<Tile name={t.brand}>
<LogoMark className="h-5" />
</Tile>
</IconSet>
</div>
);
}
function IconSet({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="flex flex-col gap-3">
<h4 className="text-sm font-semibold text-content-default">{title}</h4>
<div className="grid grid-cols-3 gap-2 sm:grid-cols-6">{children}</div>
</section>
);
}
function Tile({ name, children }: { name: string; children: React.ReactNode }) {
return (
<div className="flex flex-col items-center gap-2 rounded-lg border border-border-subtle bg-surface-raised px-2 py-3 text-content-default">
{children}
<span dir="ltr" className="max-w-full truncate text-2xs text-content-muted">
{name}
</span>
</div>
);
}A component doesn’t know which icon you pass, so flipping is your call. Button puts startIcon and endIcon on the correct side in both directions, but the glyph itself flips only if you add the class. No current component draws a directional icon itself; future ones that do (a Select chevron, a Breadcrumb separator) will flip it internally.
Icons in use
Every Lucide icon the design system and this site import today, scanned from the source at build time. Search by name or by where it’s used; hover a tile to copy its import.
ArrowDownLeftdocs/preview/direction-demoArrowLeftdocs/docs-pagerArrowRightui/examples/alert-demo +7ArrowUpRightui/examples/button-as-childCheckdocs/copy-button +1ChevronRightdocs/preview/direction-demoCircleAlertui/alert +1CircleCheckui/alert +1Componentdocs/app/pageCopydocs/copy-buttonCornerDownLeftdocs/searchDownloadui/examples/button-iconsFileTextui/examples/card-demo +1Hashdocs/searchInfoui/alert +1Languagesdocs/app/pageLayersdocs/app/pageLoaderCircleui/buttonMenudocs/mobile-navMoondocs/mode-toggleOctagonAlertdocs/calloutPlusui/examples/button-sizesRefreshCwui/examples/button-loadingSearchui/examples/input-adornments +2Sendui/examples/button-iconsSquareDasheddocs/preview/example-rendererSundocs/mode-toggleTextAlignStartdocs/docs-tocTextQuotedocs/searchTrendingUpui/examples/card-demoTriangleAlertui/alert +1Xui/alert +1