← Design System Index | Component Demo | Token Reference
IGrow Design System — Component Reference
Every component documented below renders via the same CSS class names
across HTML, React (<Button> etc.), and WordPress (get_template_part('partials/button')).
The class names are the contract.
Layout grid is fluid. Components use the
.ig-gridfamily of utility classes for responsive layouts — seetokens.cssfor the Tailwind-aligned breakpoints (sm 640, md 768, lg 1024, xl 1280, 2xl 1536).
Icons
All icons in this system come from Lucide (open-source, ISC licence). The brand team has signed off — Lucide replaces the legacy Font Awesome treatment. Full guidance: system/icons/README.md. Glossary of IGrow contexts → Lucide names: system/icons/lucide-list.md.
The IGrow icon contract: 24×24 viewBox, stroke="currentColor", stroke-width="1.75", rounded caps/joins. Don't deviate.
Three consumption paths:
<!-- 1. Sprite (recommended for HTML/WordPress) -->
<svg class="ig-icon" width="24" height="24" aria-hidden="true">
<use href="/path/to/system/icons/sprite.svg#bed"></use>
</svg>
// 2. React (built-in <Icon> helper)
import { Icon, setIconSpriteUrl } from "igrow-web-design-system/react";
setIconSpriteUrl("/path/to/system/icons/sprite.svg");
<Icon name="bed" size={18} />
// 3. WordPress (PHP helper)
igrow_icon( 'bed', [ 'size' => 18 ] );
For React projects with bundlers, lucide-react (npm i lucide-react) is a fine alternative — pass strokeWidth={1.75} to match the brand.
Table of contents
- Button
- Field & inputs
- Card (generic)
- Property card
- Stat card
- Testimonial
- Nav
- Hero
- Footer
- CTA band
- Modal
- Alert
- Accordion
- Tabs
- Pagination
- Breadcrumb
- Loaders (Spinner / Skeleton)
- Table
- Filter row & listing bar
- Empty state
1. Button
Class: .ig-btn + variant modifier.
Variants: --primary (red), --secondary (teal outline), --ghost, --inverse (for dark surfaces), --danger.
Sizes: default · --sm · --lg.
Modifiers: --block (full width), --icon-only (square).
Anatomy
<button class="ig-btn ig-btn--primary">
[optional .ig-btn__icon SVG]
[label]
[optional .ig-btn__icon SVG]
</button>
Do
- One primary button per surface — the action you most want users to take.
- Pair an outline / ghost secondary with the primary when you have two equal options.
- Always include a visible label even on icon-only buttons (use
aria-label).
Don't
- Stack three primary buttons in a row — pick one and demote the rest.
- Square the corners (
border-radius: 4px). IGrow buttons are pill-shaped. - Use the
--dangervariant for "cancel" actions — that's a ghost button. Reserve danger for destructive operations only.
Accessibility
- 3px focus ring (
--ring-default) appears on:focus-visible— never strip it. - Minimum hit area: 44 × 44 CSS pixels (achieved by
paddingdefaults). - For icon-only buttons, always set
aria-label="…". - For disabled states, use
[disabled]on<button>or[aria-disabled="true"]on<a>. Don't rely on visual styling alone.
Loading state
Set aria-busy="true" and replace the icon slot with a <Spinner />. Disable pointer events via aria-disabled="true".
Responsive behaviour
Primary CTAs in hero sections and standalone forms are full-width at mobile (use --block); switch to auto-width at sm (640 px+). Increase to --lg size at lg (1024 px+) for hero CTAs. See Responsive Guidelines → Buttons.
2. Field & inputs
Wrapper class: .ig-field + optional state modifier (--error, --success).
Children: .ig-field__label, .ig-input / .ig-textarea / .ig-select, .ig-field__helper, .ig-field__error.
Required indicator
Add data-required to the label, OR the class .ig-field__label--required. Either renders a red asterisk via CSS — no need to hand-write * in markup.
Do
- Always wire
for=on the label toid=on the input. Required for screen readers. - Use
aria-describedbyto link helper text and error messages to the input. - Add
inputmode=andautocomplete=attributes — they materially improve mobile UX.
Don't
- Use placeholder text as a label. Placeholder disappears the moment users start typing.
- Show error messages before the user has interacted with the field. Use
:user-invalidor JS to delay. - Use red colour for the error message alone — pair with an icon for colour-blind users.
Accessibility
- Error wrappers get
aria-invalid="true"on the input and an error message inrole="alert"oraria-live="polite". - Honeypot fields use
.ig-visually-hiddennotdisplay: none(bots can detectdisplay:none). - The native
accent-coloron checkboxes/radios uses brand red for free.
Responsive behaviour
Single column at base; two-column grid for multi-field forms at md (768 px+). Form containers should use max-width: var(--container-sm) (760 px) — never stretch forms to the full page width. Always set inputmode and autocomplete on inputs — they directly improve the Android 384 px experience. See Responsive Guidelines → Forms.
Native form validation (:user-invalid)
CSS already lights up the error border when a field is in an invalid state — but only after the user has interacted with it. That's :user-invalid, not :invalid. Pair with native attributes (required, type, pattern) and the browser validates for you:
<div class="ig-field">
<label class="ig-field__label" data-required>Email</label>
<input class="ig-input" type="email" required>
</div>
The field stays neutral until the user types something invalid and blurs. No JS needed. For server-rendered errors (e.g. after a failed submit), add .ig-field--error to the wrapper — both paint identically.
Switch
The .ig-switch is a styled checkbox with role="switch". AT announces it as "switch, on/off" — semantically correct. The underlying control is <input type="checkbox" role="switch"> — keep this; don't roll a custom JS toggle.
3. Card (generic)
Class: .ig-card (base) + optional .ig-card--bordered, .ig-card--interactive.
<article class="ig-card ig-card--interactive">
<div class="ig-card__media">…</div>
<div class="ig-card__body">
<h3 class="ig-card__title">…</h3>
<p class="ig-card__excerpt">…</p>
</div>
</article>
Do
- Use
.ig-card--interactiveon cards that are themselves clickable; the hover lifts and the shadow deepens. - Pick one of
box-shadow(default) ORborder(--bordered) — not both. - Add
loading="lazy"to<img>inside.ig-card__media.
Don't
- Use
.ig-card--interactiveif the card has multiple clickable targets inside; pick one primary target. - Vary border-radius across cards on the same page. The system's radius is 15px (
--radius-lg) — non-negotiable for visual cohesion.
4. Property card
Composition: .ig-card.ig-card--interactive.ig-property-card.
Anatomy: media (with .ig-card__badge) → title → price → address → excerpt → specs list → optional footer.
This is the highest-traffic card pattern on rentals.igrow.co.za — match its anatomy when adding to other sub-brands.
Spec list
.ig-property-card__specs is a <ul> with display: flex and a border-top. Order: beds → baths → m². If the property is pets_allowed, append "Pets allowed" as the last item.
Do
- Keep the title 1–2 lines max; the React component does NOT truncate so write disciplined titles.
- Format prices with thin spaces:
R 7 000notR7000orR 7,000. - Use a fallback gradient image (brand-red SVG square) when no photo is available, never a broken-image icon.
Don't
- Add a fourth spec in the row — it breaks the column rhythm at mobile widths.
- Show price with rand fractions ("R 7 000.00") on the card — round to the integer.
Accessibility
- The whole card is wrapped in
<a>for one-tap targets on mobile; the inner<h3>keeps its semantic weight, so screen-reader users still hear "heading level 3 — 1 Bedroom Apartment …".
Responsive behaviour
Grid progression: 1 col (base) → 2 col (md) → 3 col (lg) → 4 col (xl+). Card thumbnail locks to aspect-ratio: var(--aspect-card) (3/2) at all sizes — do not let it resize with text. See Responsive Guidelines → Property card grid.
5. Stat card
For dashboards. .ig-stat-card with __label, __value, optional __delta (add __delta--down to flip the colour to danger).
Do
- Round large numbers —
4.8Mnot4,837,210on the headline value. - Pair with a sparkline or chart on a hover state, not the rest state.
Don't
- Stuff more than 4 stat cards per row at desktop — readability collapses.
6. Testimonial
Quotes from clients. .ig-testimonial with __rating, __quote, __author. Rating is rendered with star characters (★ / ☆) and an aria-label for SR users.
Do
- Keep quotes to 3–4 sentences on cards. Long testimonials belong on a dedicated page.
- Use real client names (with permission). "Anonymous" testimonials read as untrustworthy.
Don't
- Add photos of the reviewer unless you have signed-off image rights.
- Use emoji ratings — the brand voice excludes emoji.
7. Nav
.ig-nav is sticky at top by default. Desktop shows .ig-nav__links; below lg they hide and .ig-nav__toggle appears. The drawer (.ig-nav-drawer[data-open="true"]) is slid in by your app's toggle handler.
Do
- Always include the [Skip to content] link before the nav (use
.ig-skip-linkfromreset.css). - Mark the current page with
aria-current="page"— the CSS handles the underline.
Don't
- Add more than 5 top-level links. The rentals nav has 3 (Home, Properties, Contact) and that's the right ceiling.
- Use icons in place of words for primary nav — text is faster to parse.
Responsive behaviour
Hamburger toggle below lg (1024 px); full nav links at lg+. Mobile height 56 px (--nav-height-mobile); desktop 72 px (--nav-height-desktop). The nav bar is always full-bleed; only the inner content is constrained. See Responsive Guidelines → Navigation.
Mobile drawer
Built on native <dialog class="ig-nav-drawer">. Call dialog.showModal() to open. Browser provides focus trap, Escape, inert on the rest of the page. Wire the toggle button's aria-expanded from a close event listener on the dialog so it stays in sync regardless of how the dialog closes.
<button class="ig-nav__toggle" aria-controls="nav-drawer" aria-expanded="false"
onclick="document.getElementById('nav-drawer').showModal()">…</button>
<dialog class="ig-nav-drawer" id="nav-drawer" aria-label="Site navigation">
<button class="ig-nav-drawer__close" onclick="this.closest('dialog').close()">×</button>
<ul class="ig-nav-drawer__links">…</ul>
</dialog>
8. Hero
.ig-hero-section is the standard photo-hero. Apply background-image: url(...) inline; the brand-gradient overlay is applied by ::before.
Variants
- Default — Rentals red→navy overlay.
--dark— navy-only overlay (use for contact / utility pages).--compact— half-height (use for sub-page headers like the contact banner).
Responsive behaviour
Single column with stacked text at base; full-bleed photo hero with overlay at md+. Use --fs-fluid-hero for the title — it handles 384 px → 1920 px without breakpoint overrides. Apply aspect-ratio: var(--aspect-banner) (21/9) at 2xl to prevent awkward stretching. See Responsive Guidelines → Hero.
Motif
Drop <img class="ig-hero-section__motif" src="banner-line-graphic.svg"> for the diagonal stripe. Only ONE motif per band. Switch to --right if your copy sits on the left.
Do
- Pick imagery that already has a strong dark area in the corner where the title goes.
- Cap the title at ~22 characters.
Don't
- Use video heroes. The brand motion vocabulary is fades + small translates.
- Apply the motif on a light surface — it's designed for dark photo backgrounds only.
9. Footer
.ig-footer with sub-brand modifier (.ig-footer--properties, etc.). Footer gradient comes from --gradient-footer-*.
Required content
- Brand logo (white version) + intro paragraph (max 36 chars wide)
- Social links (always: Facebook, Instagram, LinkedIn)
- "Contacts" column with phone + email (+ WhatsApp if available)
- "The Group" column linking to group-level pages
- "Legal" column (Cookie Policy, Privacy, PAIA Manual — POPIA-required)
.ig-footer__legalrow with copyright + statutory disclosures (PPRA / FSCA)
Do
- Always show the PPRA FFC number for property-trading sub-brands (Rentals, Properties, Store).
- Always show the FSCA FSP number for financial-services sub-brands (Home Loans, Private Wealth).
Don't
- Stack the footer columns at desktop — the 4-column grid is intentional for scannability.
10. CTA band
.ig-cta-band is the inverse-on-navy band that bookends marketing pages. Pair with .ig-container--sm to keep the copy block narrow.
Do
- Limit to one per page, near the end.
- Use the primary + inverse-outline button combination.
Don't
- Stack two CTA bands in a row. If you need two CTAs, they go inline next to each other inside the same band.
11. Modal
Built on native <dialog class="ig-modal">. Call dialog.showModal() to open, dialog.close() to close. The browser gives you focus trap, Escape-to-close, automatic inert on the rest of the page, and top-layer rendering for free.
<button onclick="document.getElementById('schedule').showModal()">Schedule a viewing</button>
<dialog class="ig-modal" id="schedule" aria-labelledby="schedule-title">
<header class="ig-modal__header">
<h2 id="schedule-title" class="ig-modal__title">Schedule a viewing</h2>
<button class="ig-modal__close" aria-label="Close" onclick="this.closest('dialog').close()">×</button>
</header>
<form method="dialog">…</form>
</dialog>
A form with method="dialog" inside the modal closes the dialog on submit — the browser does it. Click-outside-to-close needs one line of JS (if (e.target === dialog) dialog.close()).
Do
- Use
aria-labelledbypointing at the modal title'sidso SR users hear the title on open. - Return focus to the trigger element on close — the browser does this automatically with
showModal(). - Prefer
<form method="dialog">for in-modal forms so the dialog closes on submit without JS.
Don't
- Reach for
aria-modal="true"androle="dialog"— they're implicit on<dialog>and adding them is redundant. - Use a modal to confirm low-stakes actions ("Are you sure you want to save?"). Use an inline confirmation.
- Use
dialog.show()(non-modal). For an IGrow modal you always wantshowModal().
Browser support fallback
<dialog> ships in every evergreen browser since 2022 (Chromium, Firefox 98+, Safari 15.4+). The React <Modal> wrapper feature-detects showModal and falls back to setAttribute('open', '') on the rare legacy Safari.
12. Alert
.ig-alert with --info, --success, --warning, --danger. Inline page messages — for ephemeral notifications use .ig-toast.
Do
- Pair the colour with an icon. Colour-blind users miss colour-only signals.
- For dismissable alerts, include a button with
aria-label="Dismiss alert".
Don't
- Auto-dismiss critical alerts (
--danger). Let the user choose when to close. - Wrap alerts in
role="alert"except for danger alerts — info/success/warning should berole="status"to avoid interrupting screen reader flow.
13. Accordion
Built on native <details>/<summary>. Zero JavaScript. The browser handles keyboard (Enter / Space toggle), aria-expanded, and even state-restoration on browser back. The animation requires interpolate-size: allow-keywords (Chromium 129+) — where unsupported, expand/collapse is instant, which is an acceptable degradation.
<div class="ig-accordion">
<details open>
<summary>What does IGrow's rental management cost?</summary>
<div class="ig-accordion__content">Standard management is 10% + VAT…</div>
</details>
<details>
<summary>How do you vet tenants?</summary>
<div class="ig-accordion__content">Three-tier check: TPN, credit bureau…</div>
</details>
</div>
Do
- Use for FAQ sections and property detail toggles (specs, documents, location).
- Set
openon the first item if you want it pre-expanded. - Allow multiple items open at once — the default
<details>behaviour.
Don't
- Hide critical conversion info inside an accordion. If a price/feature is decision-relevant, surface it.
- Reach for
aria-expanded,aria-controls, or JS toggles. The native element handles all of it. - Force-restrict to one-open-at-a-time. If you must, use the
nameattribute (Chromium 120+):<details name="faq">— items sharing anameform an accordion-radio group.
14. Tabs
.ig-tabs__list + .ig-tabs__trigger + .ig-tabs__panel. Pill-styled. Implements the APG tab pattern — ArrowLeft / ArrowRight cycles tabs, Home / End jumps to first/last, focus uses roving tabindex so only the active tab is in tab order.
<div class="ig-tabs" data-igrow-tabs>
<div class="ig-tabs__list" role="tablist" aria-label="Property details">
<button class="ig-tabs__trigger" role="tab" id="tab-overview" aria-selected="true" aria-controls="panel-overview" tabindex="0">Overview</button>
<button class="ig-tabs__trigger" role="tab" id="tab-specs" aria-selected="false" aria-controls="panel-specs" tabindex="-1">Specs</button>
</div>
<section role="tabpanel" id="panel-overview" aria-labelledby="tab-overview" tabindex="0">…</section>
<section role="tabpanel" id="panel-specs" aria-labelledby="tab-specs" tabindex="0" hidden>…</section>
</div>
A ~30-line inline script in the demo (all.html) drives the keyboard pattern. Lift it into your app's script bundle and target [data-igrow-tabs].
No-JS fallback (progressive enhancement)
If you can't ship JS, render each panel as a regular <section id="…"> with a heading, and replace the tab buttons with anchor links to those ids. The page works without JavaScript — the user scrolls to each section instead of switching in place. Layer the tab script on top as enhancement.
Do
- Use for switching between equivalent views ("Overview", "Specs", "Documents").
- Keep tab labels to 1–2 words.
- Set
aria-labelon the<div role="tablist">so SR users know what the tab group is for.
Don't
- Use tabs for primary navigation — that's what the nav is for.
- Nest tabs inside tabs. If you need to, restructure the page.
- Forget the roving
tabindex. Tabs that aren't selected MUST betabindex="-1"so Tab moves to the panel, not the next tab.
15. Pagination
.ig-pagination with __link. Mark the current page with aria-current="page".
Do
- Show prev/next chevrons separately from the page numbers.
- Use
aria-label="Pagination"on the wrapping<nav>.
Don't
- Render an unbounded list of page numbers. The React
<Pagination>component windows correctly — use it.
16. Breadcrumb
.ig-breadcrumb is a <ol> (ordered, because position matters semantically). Last item gets aria-current="page".
Do
- Wrap in
<nav aria-label="Breadcrumb">. - Use › (single right angle quote) as the separator — applied via CSS, don't hand-type it.
Don't
- Link the last breadcrumb item to itself.
17. Loaders
Spinner
.ig-spinner is a simple CSS keyframe. Always pair with an aria-label or visible "Loading…" text. Honours prefers-reduced-motion.
Skeleton
.ig-skeleton with variants --text, --title, --media. Use to occupy space while content fetches.
Accessible loading regions
The skeleton markup is purely visual — set aria-hidden="true" on each skeleton element so SR users don't hear "blank, blank, blank". Then wrap the loading region in aria-busy="true":
<article class="ig-card" aria-busy="true" aria-label="Loading property">
<span class="ig-skeleton ig-skeleton--media" aria-hidden="true"></span>
<div class="ig-card__body">
<span class="ig-skeleton ig-skeleton--title" aria-hidden="true"></span>
<span class="ig-skeleton ig-skeleton--text" aria-hidden="true"></span>
</div>
</article>
When the real content lands, drop the aria-busy and the skeleton spans together. SR users hear the heading announce naturally.
Do
- Show a skeleton when the wait is > 300 ms but < 5 s. For longer waits use a progress bar + status text.
- Set
aria-busy="true"on the parent region andaria-hidden="true"on each skeleton element.
Don't
- Animate skeletons faster than 1.4 s — anything quicker feels seizure-y.
- Let SR users hear empty
<span>elements as content. Alwaysaria-hidden.
18. Table
.ig-table-wrap (the overflow container) + .ig-table. Pair status cells with .ig-pill variants.
Do
- Right-align numeric columns by adding
style="text-align:right"on<th>and<td>. - Sticky the header on long tables (
thead { position: sticky; top: 0 }— add per-page since it depends on the surrounding scroll context).
Responsive behaviour
.ig-table-wrap enables horizontal scroll below md (768 px). Always wrap .ig-table in .ig-table-wrap — never strip the wrapper to "save space" on mobile. If a table exceeds 6 columns, consider a card-based alternative for the mobile view. See Responsive Guidelines → Tables.
Don't
- Use tables for layout. Use grid/flex utilities.
- Add zebra striping — IGrow's row-hover background is enough.
19. Filter row & listing bar
.ig-filter-row (the bar of selects) + .ig-listing-bar (count + sort + view toggle). The "Filters" button opens a <dialog class="ig-filter-drawer"> with the remaining controls. Wire aria-expanded on the button and aria-controls pointing at the dialog's id.
The result count (.ig-listing-bar__count) should be wrapped in aria-live="polite" aria-atomic="true" so SR users hear "117 properties found" when the filters change.
<button class="ig-btn ig-btn--ghost ig-filter-row__filters-btn" type="button"
aria-expanded="false" aria-controls="filter-drawer"
onclick="document.getElementById('filter-drawer').showModal(); this.setAttribute('aria-expanded','true')">
Filters <span class="ig-filter-count" aria-label="2 active filters">2</span>
</button>
<dialog class="ig-filter-drawer" id="filter-drawer" aria-labelledby="filter-drawer-title">
<header class="ig-filter-drawer__header">
<h2 id="filter-drawer-title">All filters</h2>
<button onclick="this.closest('dialog').close()" aria-label="Close">×</button>
</header>
<div class="ig-filter-drawer__body">…</div>
<footer class="ig-filter-drawer__footer">…</footer>
</dialog>
The dialog's close event should reset the button's aria-expanded="false" so the state stays in sync regardless of how the dialog closes (Escape, backdrop click, explicit button click).
Do
- Mirror the rentals.igrow.co.za filter set: Province → Suburb → Type → Min Price → Max Price → Bedrooms.
- Show the active-filter count via
.ig-filter-counton the "Filters" button.
Don't
- Add more than 6 inline filters. Push the rest into the off-canvas drawer.
- Skip the live region on
.ig-listing-bar__count. SR users won't hear that filter changes had any effect.
20. Empty state
.ig-empty with __icon, __title, __body, plus an action button.
Do
- Always offer a way out — a "Clear filters" or "Browse all" button.
- Use sentence case, not Title Case, on the empty-state title.
Don't
- Use cute illustrations. The brand vocabulary is photo-led; an empty state should feel like a quiet space, not a cartoon.