IGrow Group of Companies

IGrow Design System — Component Reference

← Design System

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-grid family of utility classes for responsive layouts — see tokens.css for 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

  1. Button
  2. Field & inputs
  3. Card (generic)
  4. Property card
  5. Stat card
  6. Testimonial
  7. Nav
  8. Hero
  9. Footer
  10. CTA band
  11. Modal
  12. Alert
  13. Accordion
  14. Tabs
  15. Pagination
  16. Breadcrumb
  17. Loaders (Spinner / Skeleton)
  18. Table
  19. Filter row & listing bar
  20. 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 --danger variant 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 padding defaults).
  • 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 to id= on the input. Required for screen readers.
  • Use aria-describedby to link helper text and error messages to the input.
  • Add inputmode= and autocomplete= 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-invalid or 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 in role="alert" or aria-live="polite".
  • Honeypot fields use .ig-visually-hidden not display: none (bots can detect display:none).
  • The native accent-color on 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--interactive on cards that are themselves clickable; the hover lifts and the shadow deepens.
  • Pick one of box-shadow (default) OR border (--bordered) — not both.
  • Add loading="lazy" to <img> inside .ig-card__media.

Don't

  • Use .ig-card--interactive if 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 000 not R7000 or R 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.8M not 4,837,210 on 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-link from reset.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.

.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__legal row 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-labelledby pointing at the modal title's id so 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" and role="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 want showModal().

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 be role="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 open on 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 name attribute (Chromium 120+): <details name="faq"> — items sharing a name form 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-label on 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 be tabindex="-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 and aria-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. Always aria-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-count on 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.