Creating Reusable UI Primitives
Build a library of foundational UI components: buttons, inputs, modals, and drawers. Create consistent, accessible, and theme-aware primitives.
UI primitives are the building blocks of your theme’s React components. A well-designed primitive library ensures consistency, accessibility, and maintainability. Let’s build the essentials.
The Primitives We’ll Build
src/components/ui/├── Button/│ ├── Button.tsx│ ├── Button.module.css│ └── index.ts├── Input/│ ├── Input.tsx│ ├── Input.module.css│ └── index.ts├── Modal/│ ├── Modal.tsx│ ├── Modal.module.css│ └── index.ts├── Drawer/│ ├── Drawer.tsx│ ├── Drawer.module.css│ └── index.ts├── Spinner/│ ├── Spinner.tsx│ ├── Spinner.module.css│ └── index.ts├── VisuallyHidden/│ ├── VisuallyHidden.tsx│ └── index.ts└── index.tsEach component lives in its own folder with its styles and a barrel export. This makes it easy to add tests or Storybook stories later.
Button Component
The most-used primitive. Support variants, sizes, and loading states:
import { forwardRef } from 'react';import styles from './Button.module.css';import { Spinner } from './Spinner';
/* * Button Primitive * A reusable button component with variants, sizes, and loading state. * Uses forwardRef so parent components can access the underlying DOM element. */
// Extend native button props - inherits onClick, type, disabled, etc.interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { /** Visual style variant */ variant?: 'primary' | 'secondary' | 'outline' | 'ghost'; /** Size of the button */ size?: 'small' | 'medium' | 'large'; /** Shows loading spinner and disables button */ loading?: boolean; /** Makes button full width */ fullWidth?: boolean; /** Button contents */ children: React.ReactNode;}
// forwardRef allows parent components to get a ref to the actual <button>export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button( { variant = 'primary', // Default props for convenience size = 'medium', loading = false, fullWidth = false, disabled, className, children, ...props // Spread remaining props (onClick, type, etc.) }, ref) { // Build class list dynamically based on props const classNames = [ styles.button, styles[variant], // Adds .primary, .secondary, etc. styles[size], // Adds .small, .medium, .large fullWidth && styles.fullWidth, // Conditionally add loading && styles.loading, className, // Allow custom classes from parent ] .filter(Boolean) // Remove false/undefined values .join(' ');
return ( <button ref={ref} className={classNames} disabled={disabled || loading} {...props}> {/* Spinner overlays content during loading */} {loading && ( <span className={styles.spinnerWrapper}> <Spinner size="small" /> </span> )} {/* Hide text (not remove) during loading to maintain button width */} <span className={loading ? styles.hiddenText : undefined}>{children}</span> </button> );});.button { display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem; font-family: inherit; font-weight: 500; border-radius: 4px; cursor: pointer; transition: all 0.15s ease; position: relative;}
.button:disabled { opacity: 0.6; cursor: not-allowed;}
/* Variants */.primary { background: var(--color-primary, #000); color: var(--color-primary-contrast, #fff); border: none;}
.primary:hover:not(:disabled) { opacity: 0.9;}
.secondary { background: var(--color-secondary, #f5f5f5); color: var(--color-text, #000); border: none;}
.outline { background: transparent; color: var(--color-text, #000); border: 1px solid currentColor;}
.ghost { background: transparent; color: var(--color-text, #000); border: none;}
.ghost:hover:not(:disabled) { background: var(--color-secondary, #f5f5f5);}
/* Sizes */.small { padding: 0.5rem 1rem; font-size: 0.875rem;}
.medium { padding: 0.75rem 1.5rem; font-size: 1rem;}
.large { padding: 1rem 2rem; font-size: 1.125rem;}
/* States */.fullWidth { width: 100%;}
.loading { color: transparent;}
.spinnerWrapper { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;}
.hiddenText { visibility: hidden;}Input Component
Text input with label, error state, and icons:
import { forwardRef, useId } from 'react';import styles from './Input.module.css';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> { /** Label text */ label?: string; /** Error message */ error?: string; /** Helper text below input */ hint?: string; /** Icon to show on the left */ leftIcon?: React.ReactNode; /** Icon to show on the right */ rightIcon?: React.ReactNode;}
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input( { label, error, hint, leftIcon, rightIcon, className, id, ...props }, ref) { const generatedId = useId(); const inputId = id || generatedId; const errorId = `${inputId}-error`; const hintId = `${inputId}-hint`;
return ( <div className={`${styles.wrapper} ${className || ''}`}> {label && ( <label htmlFor={inputId} className={styles.label}> {label} </label> )}
<div className={`${styles.inputWrapper} ${error ? styles.hasError : ''}`}> {leftIcon && <span className={styles.leftIcon}>{leftIcon}</span>}
<input ref={ref} id={inputId} className={styles.input} aria-invalid={error ? 'true' : undefined} aria-describedby={ [error && errorId, hint && hintId].filter(Boolean).join(' ') || undefined } {...props} />
{rightIcon && <span className={styles.rightIcon}>{rightIcon}</span>} </div>
{error && ( <p id={errorId} className={styles.error} role="alert"> {error} </p> )}
{hint && !error && ( <p id={hintId} className={styles.hint}> {hint} </p> )} </div> );});.wrapper { display: flex; flex-direction: column; gap: 0.25rem;}
.label { font-size: 0.875rem; font-weight: 500; color: var(--color-text, #000);}
.inputWrapper { position: relative; display: flex; align-items: center;}
.input { width: 100%; padding: 0.75rem 1rem; font-size: 1rem; font-family: inherit; border: 1px solid var(--color-border, #ddd); border-radius: 4px; background: var(--color-background, #fff); transition: border-color 0.15s ease;}
.input:focus { outline: none; border-color: var(--color-primary, #000);}
.hasError .input { border-color: var(--color-error, #dc2626);}
.leftIcon,.rightIcon { position: absolute; display: flex; align-items: center; justify-content: center; color: var(--color-text-muted, #666);}
.leftIcon { left: 0.75rem;}
.rightIcon { right: 0.75rem;}
.inputWrapper:has(.leftIcon) .input { padding-left: 2.5rem;}
.inputWrapper:has(.rightIcon) .input { padding-right: 2.5rem;}
.error { font-size: 0.875rem; color: var(--color-error, #dc2626); margin: 0;}
.hint { font-size: 0.875rem; color: var(--color-text-muted, #666); margin: 0;}Modal Component
Accessible modal with focus trap and keyboard handling:
import { useEffect, useRef, useCallback } from 'react';import { createPortal } from 'react-dom';import styles from './Modal.module.css';import { VisuallyHidden } from './VisuallyHidden';
/* * Accessible Modal Component * * Key accessibility features: * - Focus trapping (focus stays inside modal) * - Escape key closes modal * - Body scroll lock (prevents background scrolling) * - Focus restoration (returns focus to trigger element) * - ARIA attributes for screen readers */
interface ModalProps { /** Whether the modal is open */ isOpen: boolean; /** Called when modal should close */ onClose: () => void; /** Modal title for accessibility (required even if hidden) */ title: string; /** Whether to show the title visually */ showTitle?: boolean; /** Size of the modal */ size?: 'small' | 'medium' | 'large'; /** Modal content */ children: React.ReactNode;}
export function Modal({ isOpen, onClose, title, showTitle = true, size = 'medium', children,}: ModalProps) { const modalRef = useRef<HTMLDivElement>(null); const previousActiveElement = useRef<HTMLElement | null>(null);
// Store the element that was focused before modal opened // So we can restore focus when modal closes useEffect(() => { if (isOpen) { previousActiveElement.current = document.activeElement as HTMLElement; } }, [isOpen]);
// useCallback: stable reference so it can be used in dependency array const handleKeyDown = useCallback( (event: KeyboardEvent) => { if (event.key === 'Escape') { onClose(); } }, [onClose] );
// Main effect: keyboard listener, scroll lock, focus management useEffect(() => { if (!isOpen) return;
// Listen for Escape key document.addEventListener('keydown', handleKeyDown); // Prevent body scrolling while modal is open document.body.style.overflow = 'hidden'; // Move focus into the modal modalRef.current?.focus();
// Cleanup function runs when modal closes return () => { document.removeEventListener('keydown', handleKeyDown); document.body.style.overflow = ''; // Return focus to the element that opened the modal previousActiveElement.current?.focus(); }; }, [isOpen, handleKeyDown]);
// Don't render anything if closed if (!isOpen) return null;
const modal = ( // Overlay: clicking it closes modal <div className={styles.overlay} onClick={onClose}> <div ref={modalRef} className={`${styles.modal} ${styles[size]}`} role="dialog" // Tells screen readers this is a dialog aria-modal="true" // Indicates it's modal (traps focus) aria-labelledby="modal-title" // Points to the title element tabIndex={-1} // Makes it focusable programmatically onClick={(event) => event.stopPropagation()} // Prevent overlay click > <header className={styles.header}> {/* Title is required for accessibility, but can be visually hidden */} {showTitle ? ( <h2 id="modal-title" className={styles.title}> {title} </h2> ) : ( <VisuallyHidden> <h2 id="modal-title">{title}</h2> </VisuallyHidden> )} <button className={styles.closeButton} onClick={onClose} aria-label="Close modal"> × </button> </header>
<div className={styles.content}>{children}</div> </div> </div> );
// createPortal renders modal at document.body level // This avoids z-index and overflow issues from parent containers return createPortal(modal, document.body);}.overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; padding: 1rem; z-index: 1000; animation: fadeIn 0.15s ease;}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; }}
.modal { background: var(--color-background, #fff); border-radius: 8px; max-height: 90vh; overflow: auto; animation: slideUp 0.2s ease;}
@keyframes slideUp { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); }}
.small { width: 100%; max-width: 400px;}.medium { width: 100%; max-width: 600px;}.large { width: 100%; max-width: 800px;}
.header { display: flex; align-items: center; justify-content: space-between; padding: 1rem 1.5rem; border-bottom: 1px solid var(--color-border, #eee);}
.title { margin: 0; font-size: 1.25rem; font-weight: 600;}
.closeButton { background: none; border: none; font-size: 1.5rem; cursor: pointer; padding: 0.25rem; line-height: 1; color: var(--color-text-muted, #666);}
.closeButton:hover { color: var(--color-text, #000);}
.content { padding: 1.5rem;}Drawer Component
Slide-in panel for cart, menus, and more:
import { useEffect, useRef, useCallback } from 'react';import { createPortal } from 'react-dom';import styles from './Drawer.module.css';
interface DrawerProps { /** Whether the drawer is open */ isOpen: boolean; /** Called when drawer should close */ onClose: () => void; /** Which side the drawer slides from */ position?: 'left' | 'right'; /** Drawer title for accessibility */ title: string; /** Drawer content */ children: React.ReactNode;}
export function Drawer({ isOpen, onClose, position = 'right', title, children }: DrawerProps) { const drawerRef = useRef<HTMLDivElement>(null);
const handleKeyDown = useCallback( (event: KeyboardEvent) => { if (event.key === 'Escape') { onClose(); } }, [onClose] );
useEffect(() => { if (!isOpen) return;
document.addEventListener('keydown', handleKeyDown); document.body.style.overflow = 'hidden'; drawerRef.current?.focus();
return () => { document.removeEventListener('keydown', handleKeyDown); document.body.style.overflow = ''; }; }, [isOpen, handleKeyDown]);
const drawer = ( <div className={`${styles.overlay} ${isOpen ? styles.open : ''}`} onClick={onClose}> <div ref={drawerRef} className={`${styles.drawer} ${styles[position]} ${isOpen ? styles.open : ''}`} role="dialog" aria-modal="true" aria-label={title} tabIndex={-1} onClick={(event) => event.stopPropagation()} > {children} </div> </div> );
return createPortal(drawer, document.body);}.overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0); z-index: 1000; pointer-events: none; transition: background 0.3s ease;}
.overlay.open { background: rgba(0, 0, 0, 0.5); pointer-events: auto;}
.drawer { position: fixed; top: 0; height: 100%; width: 100%; max-width: 400px; background: var(--color-background, #fff); transition: transform 0.3s ease; overflow-y: auto;}
.right { right: 0; transform: translateX(100%);}
.left { left: 0; transform: translateX(-100%);}
.drawer.open { transform: translateX(0);}Spinner Component
Loading indicator:
import styles from './Spinner.module.css';
interface SpinnerProps { /** Size of the spinner */ size?: 'small' | 'medium' | 'large'; /** Accessible label */ label?: string;}
export function Spinner({ size = 'medium', label = 'Loading' }: SpinnerProps) { return ( <span className={`${styles.spinner} ${styles[size]}`} role="status"> <span className={styles.visuallyHidden}>{label}</span> </span> );}.spinner { display: inline-block; border: 2px solid var(--color-border, #eee); border-top-color: var(--color-primary, #000); border-radius: 50%; animation: spin 0.8s linear infinite;}
.small { width: 16px; height: 16px;}.medium { width: 24px; height: 24px;}.large { width: 40px; height: 40px;}
@keyframes spin { to { transform: rotate(360deg); }}
.visuallyHidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;}VisuallyHidden Component
For screen reader-only content:
import styles from './VisuallyHidden.module.css';
interface VisuallyHiddenProps { children: React.ReactNode;}
export function VisuallyHidden({ children }: VisuallyHiddenProps) { return <span className={styles.visuallyHidden}>{children}</span>;}.visuallyHidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;}Barrel Exports
Each component folder has its own index.ts that exports the component:
export { Button } from './Button';Then the main ui/index.ts re-exports everything:
export { Button } from './Button';export { Input } from './Input';export { Modal } from './Modal';export { Drawer } from './Drawer';export { Spinner } from './Spinner';export { VisuallyHidden } from './VisuallyHidden';This allows clean imports from anywhere in your app:
import { Button, Input, Modal } from '@/components/ui';Using Primitives
Import and use in your feature components:
import { Button, Input, Modal, Drawer, Spinner } from '@/components/ui';
function NewsletterSignup() { const [email, setEmail] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState('');
const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); setLoading(true); // Submit logic... };
return ( <form onSubmit={handleSubmit}> <Input type="email" label="Email address" value={email} onChange={(event) => setEmail(event.target.value)} error={error} /> <Button type="submit" loading={loading}> Subscribe </Button> </form> );}Key Takeaways
- Primitives are building blocks: Keep them simple, composable, and reusable
- Use CSS variables: Allow theme customization through CSS custom properties
- Accessibility first: ARIA attributes, keyboard handling, focus management
- Forward refs: Allow parent components to access DOM elements
- Consistent API: Similar props across similar components (size, variant, etc.)
- Portal for overlays: Modals and drawers render outside the component tree
- Barrel exports: Single import for all UI components
Your React foundation is now complete! In the next module, we’ll tackle state management and integrate with Shopify’s AJAX API.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...