Reusable Content Section Components
Build flexible content section components with React for rich text, image layouts, and feature grids. Learn patterns for composable sections that integrate with Shopify theme editor.
Content sections are the building blocks of marketing pages—they display rich text, images, videos, and call-to-actions. Let’s build a library of reusable content components that work seamlessly with Shopify’s theme editor.
Theme Integration
Each content section type has its own Liquid section file that passes data to React:
sections/rich-text.liquid└── <div id="rich-text-{{ section.id }}"> └── RichTextSection (React) ← You are here
sections/image-with-text.liquid└── <div id="image-text-{{ section.id }}"> └── ImageWithText (React) ← You are here
sections/feature-grid.liquid└── <div id="feature-grid-{{ section.id }}"> └── FeatureGrid (React) ← You are hereEach section passes its block data via JSON script tags. The section.id ensures unique mount points when sections are used multiple times.
Data Source
| Prop/State | Source | Liquid Field |
|---|---|---|
| RichTextSection | ||
heading | JSON script element | section.settings.heading |
headingSize | JSON script element | section.settings.heading_size |
content | JSON script element | section.settings.text (richtext) |
alignment | JSON script element | section.settings.alignment |
button | JSON script element | section.settings.button_text, button_link, button_style |
background | JSON script element | section.settings.background |
| ImageWithText | ||
image.src | JSON script element | section.settings.image | image_url |
image.alt | JSON script element | section.settings.image.alt |
heading | JSON script element | section.settings.heading |
content | JSON script element | section.settings.text |
imagePosition | JSON script element | section.settings.image_position |
| FeatureGrid | ||
features[] | JSON script element | section.blocks (type: feature) |
features[].icon | JSON script element | block.settings.icon |
features[].heading | JSON script element | block.settings.heading |
features[].text | JSON script element | block.settings.text |
columns | JSON script element | section.settings.columns |
Note: All content section props are serialized from Liquid section settings and blocks. These sections are configured via the Shopify theme editor.
Content Section Patterns
┌────────────────────────────────────────────────────────────────────────────────┐│ CONTENT SECTION TYPES ││ ││ ┌─────────────────────────────────────────────────────────────────────────┐ ││ │ RICH TEXT SECTION │ ││ │ ┌───────────────────────────────────────────────────────────────────┐ │ ││ │ │ Welcome to Our Store │ │ ││ │ │ We're dedicated to bringing you quality products that make │ │ ││ │ │ a difference. Founded in 2020, we've grown from a small... │ │ ││ │ │ [Learn More] │ │ ││ │ └───────────────────────────────────────────────────────────────────┘ │ ││ └─────────────────────────────────────────────────────────────────────────┘ ││ ││ ┌─────────────────────────────────────────────────────────────────────────┐ ││ │ IMAGE WITH TEXT │ ││ │ ┌─────────────────────────┐ ┌───────────────────────────────────────┐ │ ││ │ │ │ │ Our Story │ │ ││ │ │ [IMAGE] │ │ From humble beginnings to a │ │ ││ │ │ │ │ global brand, we've stayed true... │ │ ││ │ │ │ │ [Shop Collection] │ │ ││ │ └─────────────────────────┘ └───────────────────────────────────────┘ │ ││ └─────────────────────────────────────────────────────────────────────────┘ ││ ││ ┌─────────────────────────────────────────────────────────────────────────┐ ││ │ FEATURE GRID │ ││ │ ┌───────────────┐ ┌───────────────┐ ┌───────────────┐ │ ││ │ │ [icon] │ │ [icon] │ │ [icon] │ │ ││ │ │ Free Shipping│ │ Easy Returns │ │ 24/7 Support │ │ ││ │ │ On orders $50+│ │ 30-day policy│ │ We're here │ │ ││ │ └───────────────┘ └───────────────┘ └───────────────┘ │ ││ └─────────────────────────────────────────────────────────────────────────┘ │└────────────────────────────────────────────────────────────────────────────────┘Section Wrapper Component
import { ReactNode } from 'react';import styles from './SectionWrapper.module.css';
interface SectionWrapperProps { children: ReactNode; id?: string; background?: 'none' | 'primary' | 'secondary' | 'accent'; paddingTop?: 'none' | 'small' | 'medium' | 'large'; paddingBottom?: 'none' | 'small' | 'medium' | 'large'; width?: 'narrow' | 'normal' | 'wide' | 'full'; className?: string;}
/** * SectionWrapper provides consistent spacing and background * options for all content sections. */export function SectionWrapper({ children, id, background = 'none', paddingTop = 'medium', paddingBottom = 'medium', width = 'normal', className = '',}: SectionWrapperProps) { const classes = [ styles.section, styles[`bg-${background}`], styles[`pt-${paddingTop}`], styles[`pb-${paddingBottom}`], styles[`width-${width}`], className, ] .filter(Boolean) .join(' ');
return ( <section id={id} className={classes}> <div className={styles.container}>{children}</div> </section> );}.section { width: 100%;}
/* Background variants */.bg-none { background-color: transparent;}
.bg-primary { background-color: var(--color-background);}
.bg-secondary { background-color: var(--color-surface);}
.bg-accent { background-color: var(--color-accent-bg, var(--color-primary)); color: var(--color-accent-text, #fff);}
/* Padding top */.pt-none { padding-top: 0;}.pt-small { padding-top: 2rem;}.pt-medium { padding-top: 4rem;}.pt-large { padding-top: 6rem;}
/* Padding bottom */.pb-none { padding-bottom: 0;}.pb-small { padding-bottom: 2rem;}.pb-medium { padding-bottom: 4rem;}.pb-large { padding-bottom: 6rem;}
/* Container widths */.container { margin: 0 auto; padding: 0 1.5rem;}
.width-narrow .container { max-width: 680px;}
.width-normal .container { max-width: 1000px;}
.width-wide .container { max-width: 1200px;}
.width-full .container { max-width: none; padding: 0;}
/* Responsive padding */@media (max-width: 768px) { .pt-medium { padding-top: 3rem; } .pt-large { padding-top: 4rem; } .pb-medium { padding-bottom: 3rem; } .pb-large { padding-bottom: 4rem; }}RichText Section
import { SectionWrapper } from '../SectionWrapper';import { Button } from '@/components/ui';import styles from './RichTextSection.module.css';
interface RichTextSectionProps { id?: string; heading?: string; headingSize?: 'small' | 'medium' | 'large'; content: string; alignment?: 'left' | 'center' | 'right'; button?: { text: string; url: string; style?: 'primary' | 'secondary' | 'outline'; }; background?: 'none' | 'primary' | 'secondary' | 'accent';}
/** * RichTextSection displays formatted text content with * optional heading and call-to-action button. */export function RichTextSection({ id, heading, headingSize = 'medium', content, alignment = 'center', button, background = 'none',}: RichTextSectionProps) { return ( <SectionWrapper id={id} background={background} width="narrow"> <div className={`${styles.content} ${styles[`align-${alignment}`]}`}> {/* Heading */} {heading && ( <h2 className={`${styles.heading} ${styles[`heading-${headingSize}`]}`}> {heading} </h2> )}
{/* Rich text content */} <div className={styles.text} dangerouslySetInnerHTML={{ __html: content }} />
{/* Call-to-action button */} {button && ( <div className={styles.buttonWrapper}> <Button as="a" href={button.url} variant={button.style || 'primary'} > {button.text} </Button> </div> )} </div> </SectionWrapper> );}.content { display: flex; flex-direction: column; gap: 1.5rem;}
/* Text alignment */.align-left { text-align: left; align-items: flex-start;}
.align-center { text-align: center; align-items: center;}
.align-right { text-align: right; align-items: flex-end;}
/* Heading sizes */.heading { margin: 0; font-weight: 600; line-height: 1.2;}
.heading-small { font-size: 1.5rem;}
.heading-medium { font-size: 2rem;}
.heading-large { font-size: 2.5rem;}
/* Rich text styling */.text { font-size: 1.125rem; line-height: 1.7; color: var(--color-text-muted);}
.text p { margin: 0 0 1rem;}
.text p:last-child { margin-bottom: 0;}
.text strong { color: var(--color-text); font-weight: 600;}
.text a { color: var(--color-primary); text-decoration: underline;}
.text a:hover { text-decoration: none;}
/* Button wrapper */.buttonWrapper { margin-top: 0.5rem;}
/* Responsive */@media (max-width: 768px) { .heading-medium { font-size: 1.75rem; } .heading-large { font-size: 2rem; } .text { font-size: 1rem; }}ImageWithText Section
import { SectionWrapper } from '../SectionWrapper';import { Button } from '@/components/ui';import { OptimizedImage } from '@/components/ui/OptimizedImage';import styles from './ImageWithText.module.css';
interface ImageWithTextProps { id?: string; image: { src: string; alt: string; width?: number; height?: number; }; heading?: string; content: string; button?: { text: string; url: string; style?: 'primary' | 'secondary' | 'outline'; }; imagePosition?: 'left' | 'right'; imageSize?: 'small' | 'medium' | 'large'; verticalAlign?: 'top' | 'center' | 'bottom'; background?: 'none' | 'primary' | 'secondary' | 'accent';}
/** * ImageWithText displays an image alongside text content. * Supports flexible positioning and sizing options. */export function ImageWithText({ id, image, heading, content, button, imagePosition = 'left', imageSize = 'medium', verticalAlign = 'center', background = 'none',}: ImageWithTextProps) { const layoutClass = imagePosition === 'right' ? styles.reversed : ''; const alignClass = styles[`align-${verticalAlign}`]; const sizeClass = styles[`image-${imageSize}`];
return ( <SectionWrapper id={id} background={background} width="wide"> <div className={`${styles.grid} ${layoutClass} ${alignClass} ${sizeClass}`}> {/* Image column */} <div className={styles.imageColumn}> <div className={styles.imageWrapper}> <OptimizedImage src={image.src} alt={image.alt} width={image.width || 800} height={image.height || 600} className={styles.image} /> </div> </div>
{/* Text column */} <div className={styles.textColumn}> {heading && <h2 className={styles.heading}>{heading}</h2>}
<div className={styles.content} dangerouslySetInnerHTML={{ __html: content }} />
{button && ( <div className={styles.buttonWrapper}> <Button as="a" href={button.url} variant={button.style || 'primary'} > {button.text} </Button> </div> )} </div> </div> </SectionWrapper> );}.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 3rem;}
/* Reverse layout for image on right */.reversed { direction: rtl;}
.reversed > * { direction: ltr;}
/* Vertical alignment */.align-top { align-items: flex-start;}
.align-center { align-items: center;}
.align-bottom { align-items: flex-end;}
/* Image sizes */.image-small { grid-template-columns: 1fr 2fr;}
.image-small.reversed { grid-template-columns: 2fr 1fr;}
.image-medium { grid-template-columns: 1fr 1fr;}
.image-large { grid-template-columns: 3fr 2fr;}
.image-large.reversed { grid-template-columns: 2fr 3fr;}
/* Image column */.imageColumn { overflow: hidden;}
.imageWrapper { position: relative; border-radius: var(--radius-lg, 8px); overflow: hidden;}
.image { width: 100%; height: auto; display: block; object-fit: cover;}
/* Text column */.textColumn { display: flex; flex-direction: column; justify-content: center; gap: 1.25rem;}
.heading { margin: 0; font-size: 2rem; font-weight: 600; line-height: 1.2;}
.content { font-size: 1.0625rem; line-height: 1.7; color: var(--color-text-muted);}
.content p { margin: 0 0 1rem;}
.content p:last-child { margin-bottom: 0;}
.buttonWrapper { margin-top: 0.5rem;}
/* Responsive: stack on tablet */@media (max-width: 900px) { .grid, .image-small, .image-medium, .image-large, .image-small.reversed, .image-large.reversed { grid-template-columns: 1fr; gap: 2rem; }
.reversed { direction: ltr; }
.heading { font-size: 1.75rem; }}FeatureGrid Section
import { SectionWrapper } from '../SectionWrapper';import styles from './FeatureGrid.module.css';
interface Feature { icon: string; heading: string; text: string;}
interface FeatureGridProps { id?: string; heading?: string; features: Feature[]; columns?: 2 | 3 | 4; background?: 'none' | 'primary' | 'secondary' | 'accent';}
/** * FeatureGrid displays a grid of features/benefits with icons. * Great for trust signals, USPs, or service highlights. */export function FeatureGrid({ id, heading, features, columns = 3, background = 'none',}: FeatureGridProps) { return ( <SectionWrapper id={id} background={background} width="wide"> {/* Section heading */} {heading && <h2 className={styles.sectionHeading}>{heading}</h2>}
{/* Features grid */} <div className={styles.grid} style={{ '--columns': columns } as React.CSSProperties} > {features.map((feature, index) => ( <div key={index} className={styles.feature}> {/* Icon */} <div className={styles.iconWrapper}> <FeatureIcon name={feature.icon} /> </div>
{/* Text content */} <h3 className={styles.heading}>{feature.heading}</h3> <p className={styles.text}>{feature.text}</p> </div> ))} </div> </SectionWrapper> );}
/** * FeatureIcon renders common feature icons. */function FeatureIcon({ name }: { name: string }) { const icons: Record<string, JSX.Element> = { shipping: ( <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"> <rect x="1" y="3" width="15" height="13" /> <polygon points="16 8 20 8 23 11 23 16 16 16 16 8" /> <circle cx="5.5" cy="18.5" r="2.5" /> <circle cx="18.5" cy="18.5" r="2.5" /> </svg> ), returns: ( <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"> <polyline points="1 4 1 10 7 10" /> <path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" /> </svg> ), support: ( <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"> <path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" /> </svg> ), secure: ( <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"> <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" /> <path d="m9 12 2 2 4-4" /> </svg> ), quality: ( <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"> <circle cx="12" cy="8" r="7" /> <polyline points="8.21 13.89 7 23 12 20 17 23 15.79 13.88" /> </svg> ), gift: ( <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"> <polyline points="20 12 20 22 4 22 4 12" /> <rect x="2" y="7" width="20" height="5" /> <line x1="12" y1="22" x2="12" y2="7" /> <path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z" /> <path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z" /> </svg> ), };
return icons[name] || icons['quality'];}.sectionHeading { margin: 0 0 2.5rem; font-size: 2rem; font-weight: 600; text-align: center;}
.grid { display: grid; grid-template-columns: repeat(var(--columns, 3), 1fr); gap: 2rem;}
.feature { text-align: center; padding: 1.5rem;}
.iconWrapper { display: inline-flex; align-items: center; justify-content: center; width: 64px; height: 64px; margin-bottom: 1rem; border-radius: var(--radius-full); background-color: var(--color-surface); color: var(--color-primary);}
.heading { margin: 0 0 0.5rem; font-size: 1.125rem; font-weight: 600;}
.text { margin: 0; font-size: 0.9375rem; color: var(--color-text-muted); line-height: 1.5;}
/* Responsive */@media (max-width: 900px) { .grid { grid-template-columns: repeat(2, 1fr); }}
@media (max-width: 600px) { .grid { grid-template-columns: 1fr; }
.feature { display: flex; flex-direction: column; align-items: center; text-align: center; }}Scroll Reveal Animation
import { useEffect, useRef, useState } from 'react';
interface UseScrollRevealOptions { threshold?: number; rootMargin?: string; triggerOnce?: boolean;}
/** * Hook for triggering animations when elements enter the viewport. */export function useScrollReveal<T extends HTMLElement>({ threshold = 0.1, rootMargin = '0px', triggerOnce = true,}: UseScrollRevealOptions = {}) { const ref = useRef<T>(null); const [isVisible, setIsVisible] = useState(false);
useEffect(() => { const element = ref.current; if (!element) return;
const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) { setIsVisible(true); if (triggerOnce) { observer.unobserve(element); } } else if (!triggerOnce) { setIsVisible(false); } }, { threshold, rootMargin } );
observer.observe(element);
return () => observer.disconnect(); }, [threshold, rootMargin, triggerOnce]);
return { ref, isVisible };}import { ReactNode } from 'react';import { useScrollReveal } from '@/hooks/useScrollReveal';import styles from './AnimatedSection.module.css';
interface AnimatedSectionProps { children: ReactNode; animation?: 'fadeIn' | 'fadeUp' | 'fadeLeft' | 'fadeRight' | 'scale'; delay?: number; className?: string;}
/** * AnimatedSection wraps content with scroll-triggered animations. */export function AnimatedSection({ children, animation = 'fadeUp', delay = 0, className = '',}: AnimatedSectionProps) { const { ref, isVisible } = useScrollReveal<HTMLDivElement>();
const animationClass = isVisible ? styles[animation] : styles.hidden;
return ( <div ref={ref} className={`${styles.wrapper} ${animationClass} ${className}`} style={{ transitionDelay: `${delay}ms` }} > {children} </div> );}.wrapper { transition: opacity 0.6s ease, transform 0.6s ease;}
/* Hidden state */.hidden { opacity: 0;}
/* Animation variants */.fadeIn { opacity: 1;}
.fadeUp { opacity: 1; transform: translateY(0);}
.hidden.fadeUp { transform: translateY(30px);}
.fadeLeft { opacity: 1; transform: translateX(0);}
.hidden.fadeLeft { transform: translateX(-30px);}
.fadeRight { opacity: 1; transform: translateX(0);}
.hidden.fadeRight { transform: translateX(30px);}
.scale { opacity: 1; transform: scale(1);}
.hidden.scale { transform: scale(0.95);}
/* Reduce motion for accessibility */@media (prefers-reduced-motion: reduce) { .wrapper { transition: none; }
.hidden { opacity: 1; transform: none; }}Liquid Section Integration
{% comment %} sections/rich-text.liquid {% endcomment %}
<div id="rich-text-{{ section.id }}" data-component="rich-text-section"></div>
<script type="application/json" id="rich-text-data-{{ section.id }}"> { "id": {{ section.id | json }}, "heading": {{ section.settings.heading | json }}, "headingSize": {{ section.settings.heading_size | json }}, "content": {{ section.settings.text | json }}, "alignment": {{ section.settings.alignment | json }}, "background": {{ section.settings.background | json }}, {%- if section.settings.button_text != blank -%} "button": { "text": {{ section.settings.button_text | json }}, "url": {{ section.settings.button_link | json }}, "style": {{ section.settings.button_style | json }} } {%- else -%} "button": null {%- endif -%} }</script>
{% schema %}{ "name": "Rich text", "settings": [ { "type": "text", "id": "heading", "label": "Heading" }, { "type": "select", "id": "heading_size", "label": "Heading size", "options": [ { "value": "small", "label": "Small" }, { "value": "medium", "label": "Medium" }, { "value": "large", "label": "Large" } ], "default": "medium" }, { "type": "richtext", "id": "text", "label": "Text", "default": "<p>Share your brand story with customers.</p>" }, { "type": "select", "id": "alignment", "label": "Text alignment", "options": [ { "value": "left", "label": "Left" }, { "value": "center", "label": "Center" }, { "value": "right", "label": "Right" } ], "default": "center" }, { "type": "select", "id": "background", "label": "Background", "options": [ { "value": "none", "label": "None" }, { "value": "primary", "label": "Primary" }, { "value": "secondary", "label": "Secondary" } ], "default": "none" }, { "type": "text", "id": "button_text", "label": "Button text" }, { "type": "url", "id": "button_link", "label": "Button link" }, { "type": "select", "id": "button_style", "label": "Button style", "options": [ { "value": "primary", "label": "Primary" }, { "value": "secondary", "label": "Secondary" }, { "value": "outline", "label": "Outline" } ], "default": "primary" } ], "presets": [ { "name": "Rich text" } ]}{% endschema %}Mounting Content Sections
import { createRoot } from 'react-dom/client';import { RichTextSection } from '@/components/content/RichTextSection';import { ImageWithText } from '@/components/content/ImageWithText';import { FeatureGrid } from '@/components/content/FeatureGrid';
type SectionComponent = typeof RichTextSection | typeof ImageWithText | typeof FeatureGrid;
const COMPONENTS: Record<string, SectionComponent> = { 'rich-text-section': RichTextSection, 'image-with-text': ImageWithText, 'feature-grid': FeatureGrid,};
/** * Mount all content section components on the page. */export function mountContentSections() { // Find all section mount points. const sections = document.querySelectorAll('[data-component]');
sections.forEach((element) => { const componentName = element.getAttribute('data-component'); if (!componentName || !COMPONENTS[componentName]) return;
// Find corresponding data script. const sectionId = element.id.split('-').pop(); const dataScript = document.getElementById(`${componentName.replace('-section', '')}-data-${sectionId}`);
if (!dataScript) return;
try { const data = JSON.parse(dataScript.textContent || '{}'); const Component = COMPONENTS[componentName];
createRoot(element).render(<Component {...data} />); } catch (error) { console.error(`Failed to mount ${componentName}:`, error); } });}Key Takeaways
- Composable sections: Use SectionWrapper for consistent spacing and backgrounds
- Flexible layouts: Support multiple alignment, sizing, and positioning options
- Rich content types: Handle text, images, and structured content
- Scroll animations: Add reveal effects with IntersectionObserver
- Accessibility: Respect prefers-reduced-motion for animations
- Liquid integration: Pass section settings as JSON for React consumption
- Reusable patterns: Build a library of sections that work together
You’ve now completed Module 11 and have a solid foundation of footer and content components. In the next module, we’ll explore customer account components for login, registration, and order management.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...