Practical guide to building reusable MintyFlask components - patterns, macros, and best practices

Component Development User Guide

Overview

This guide covers practical component development in MintyFlask. It assumes you've read the Quick Start and understand basic component usage.

What you'll learn:

  • Building semantic CSS components
  • Creating configurable Jinja2 macros
  • Component composition patterns
  • State management and variants
  • Accessibility and responsive design
  • Testing and documentation

Component Architecture

MintyFlask components follow a two-layer architecture:

Layer 1: CSS Components (Semantic classes)

@layer components {
  .card-base {
    /* styles */
  }
  .card-title {
    /* styles */
  }
  .card-intent-primary {
    /* modifier */
  }
}

Layer 2: Macro Components (Optional orchestration)

{% macro card(title, content, intent="content") %}
  <div class="card-base card-intent-{{ intent }}">
    <h3 class="card-title">{{ title }}</h3>
    <div class="card-body">{{ content }}</div>
  </div>
{% endmacro %}

This separation allows:

  • Direct CSS usage for simple cases
  • Macro orchestration for complex logic
  • Theme customisation at CSS level
  • Consistency across all usage

Building CSS Components

Component Structure

Every CSS component follows this pattern:

@layer components {
  /* Base component */
  .{prefix}-{component}-base {
    /* Core structural styles */
  }

  /* Component elements */
  .{prefix}-{component}-{element} {
    /* Element-specific styles */
  }

  /* Intent modifiers */
  .{prefix}-{component}-intent-{value} {
    /* Intentmodifier styles */
  }

  /* Size modifiers */
  .{prefix}-{component}-size-{value} {
    /* Size modifier styles */
  }

  /* State modifiers */
  .{prefix}-{component}-state-{value} {
    /* State modifier styles */
  }

  /* Dark mode */
  .dark .{prefix}-{component}-base {
    /* Dark mode overrides */
  }
}

Example: Building a Project Card Component

Goal: Create a portfolio project card with image, title, description, and tags.

Step 1: Define base component

/* mixins/portfolio/static/css/portfolio.css */

@layer components {
  /* Base card structure */
  .portfolio-card-base {
    background: var(--color-surface);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-lg);
    overflow: hidden;
    transition:
      transform 0.2s ease,
      box-shadow 0.2s ease;
  }

  .portfolio-card-base:hover {
    transform: translateY(-4px);
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
  }

  /* Card elements */
  .portfolio-card-image {
    width: 100%;
    aspect-ratio: 16 / 9;
    object-fit: cover;
  }

  .portfolio-card-content {
    padding: var(--spacing-lg);
  }

  .portfolio-card-title {
    font-size: var(--text-xl);
    font-weight: 600;
    margin-bottom: var(--spacing-sm);
    color: var(--color-text-primary);
  }

  .portfolio-card-description {
    color: var(--color-text-secondary);
    line-height: 1.6;
    margin-bottom: var(--spacing-md);
  }

  .portfolio-card-tags {
    display: flex;
    flex-wrap: wrap;
    gap: var(--spacing-sm);
  }

  .portfolio-card-tag {
    padding: var(--spacing-xs) var(--spacing-sm);
    background: var(--color-surface-elevated);
    border-radius: var(--radius-sm);
    font-size: var(--text-sm);
    color: var(--color-text-secondary);
  }
}

Step 2: Add size variants

@layer components {
  /* Small variant */
  .portfolio-card-size-sm .portfolio-card-content {
    padding: var(--spacing-md);
  }

  .portfolio-card-size-sm .portfolio-card-title {
    font-size: var(--text-lg);
  }

  /* Large variant */
  .portfolio-card-size-lg .portfolio-card-content {
    padding: var(--spacing-xl);
  }

  .portfolio-card-size-lg .portfolio-card-title {
    font-size: var(--text-2xl);
  }
}

Step 3: Add intent variants

@layer components {
  /* Featured intent - highlighted project */
  .portfolio-card-intent-featured {
    border-width: 2px;
    border-color: var(--color-primary);
  }

  .portfolio-card-intent-featured .portfolio-card-title {
    color: var(--color-primary);
  }

  /* Client work intent - subtle styling */
  .portfolio-card-intent-client {
    background: var(--color-surface-elevated);
  }
}

Step 4: Add dark mode

@layer components {
  .dark .portfolio-card-base {
    background: var(--color-surface-dark);
    border-color: var(--color-border-dark);
  }

  .dark .portfolio-card-base:hover {
    box-shadow: 0 8px 24px rgba(255, 255, 255, 0.1);
  }

  .dark .portfolio-card-tag {
    background: var(--color-border-dark);
  }
}

Step 5: Usage in template

<!-- Basic usage -->
<article class="portfolio-card-base">
  <img src="project.jpg" alt="Project" class="portfolio-card-image" />
  <div class="portfolio-card-content">
    <h3 class="portfolio-card-title">Amazing Project</h3>
    <p class="portfolio-card-description">
      A beautiful web application built with React and Node.js.
    </p>
    <div class="portfolio-card-tags">
      <span class="portfolio-card-tag">React</span>
      <span class="portfolio-card-tag">Node.js</span>
    </div>
  </div>
</article>

<!-- Featured variant -->
<article
  class="portfolio-card-base portfolio-card-intent-featured portfolio-card-size-lg"
>
  <!-- Content -->
</article>

Creating Jinja2 Macros

Basic Macro Pattern

{# components/project-card.html #}

{% macro project_card(project, size="md", intent="default") %}
<article class="portfolio-card-base
                {% if size %}portfolio-card-size-{{ size }}{% endif %}
                {% if intent != 'default' %}portfolio-card-intent-{{ intent }}{% endif %}">
  {% if project.image %}
  <img src="{{ project.image }}"
       alt="{{ project.title }}"
       class="portfolio-card-image">
  {% endif %}

  <div class="portfolio-card-content">
    <h3 class="portfolio-card-title">{{ project.title }}</h3>

    {% if project.description %}
    <p class="portfolio-card-description">{{ project.description }}</p>
    {% endif %}

    {% if project.tags %}
    <div class="portfolio-card-tags">
      {% for tag in project.tags %}
      <span class="portfolio-card-tag">{{ tag }}</span>
      {% endfor %}
    </div>
    {% endif %}
  </div>
</article>
{% endmacro %}

Advanced Macro with Configuration

{# components/project-card.html #}

{% macro project_card(
    project,
    size="md",
    intent="default",
    show_image=true,
    show_description=true,
    show_tags=true,
    show_link=true,
    max_tags=none
) %}
<article class="portfolio-card-base
                {% if size %}portfolio-card-size-{{ size }}{% endif %}
                {% if intent != 'default' %}portfolio-card-intent-{{ intent }}{% endif %}"
         {% if show_link and project.url %}
         onclick="window.location='{{ project.url }}'"
         style="cursor: pointer;"
         {% endif %}>

  {% if show_image and project.image %}
  <img src="{{ project.image }}"
       alt="{{ project.title }}"
       class="portfolio-card-image"
       loading="lazy">
  {% endif %}

  <div class="portfolio-card-content">
    <h3 class="portfolio-card-title">
      {% if show_link and project.url %}
      <a href="{{ project.url }}" class="hover:underline">
        {{ project.title }}
      </a>
      {% else %}
        {{ project.title }}
      {% endif %}
    </h3>

    {% if show_description and project.description %}
    <p class="portfolio-card-description">
      {{ project.description }}
    </p>
    {% endif %}

    {% if show_tags and project.tags %}
    <div class="portfolio-card-tags">
      {% set displayed_tags = project.tags[:max_tags] if max_tags else project.tags %}
      {% for tag in displayed_tags %}
      <span class="portfolio-card-tag">{{ tag }}</span>
      {% endfor %}
      {% if max_tags and project.tags|length > max_tags %}
      <span class="portfolio-card-tag">
        +{{ project.tags|length - max_tags }} more
      </span>
      {% endif %}
    </div>
    {% endif %}
  </div>
</article>
{% endmacro %}

Using the Macro

{# In your template #}
{% from "components/project-card.html" import project_card with context %}

{# Basic usage #}
{{ project_card(project) }}

{# Featured large card #}
{{ project_card(project, size="lg", intent="featured") }}

{# Card without image, limited tags #}
{{ project_card(
    project,
    show_image=false,
    max_tags=3
) }}

{# Grid of projects #}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
  {% for project in projects %}
    {{ project_card(project, size="sm") }}
  {% endfor %}
</div>

Component Composition

Pattern 1: Macro Calling Macro

{# components/cards.html #}

{% macro simple_card(title, content) %}
<div class="card-base card-intent-content">
  <div class="card-body">
    <h3 class="card-title">{{ title }}</h3>
    <p class="card-description">{{ content }}</p>
  </div>
</div>
{% endmacro %}

{% macro info_card(title, content, icon=none) %}
<div class="card-base card-intent-info">
  <div class="card-header">
    {% if icon %}
    <div class="text-2xl mb-2">{{ icon }}</div>
    {% endif %}
    <h3 class="card-title">{{ title }}</h3>
  </div>
  <div class="card-body">
    <p class="card-description">{{ content }}</p>
  </div>
</div>
{% endmacro %}

{% macro feature_grid(features) %}
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
  {% for feature in features %}
    {{ info_card(feature.title, feature.description, feature.icon) }}
  {% endfor %}
</div>
{% endmacro %}

Pattern 2: Partial with Macro

{# partials/post-card.html #}
{% from "blog-components.html" import
   post_metadata_enhanced,
   post_tag_cloud
   with context %}

<article class="blog-post-card blog-post-card-size-{{ card_size|default('md') }}">
  <div class="blog-post-header">
    {{ post_metadata_enhanced(post, show_reading_time=true) }}
  </div>

  <div class="blog-post-body">
    <h2 class="blog-post-title">
      <a href="{{ url_for('blog_bp.post_view', slug=post.slug) }}">
        {{ post.meta.title }}
      </a>
    </h2>

    {% if post.meta.summary %}
    <p class="blog-post-excerpt">{{ post.meta.summary }}</p>
    {% endif %}

    {{ post_tag_cloud(post) }}
  </div>
</article>

Pattern 3: Layout with Slots

{# layouts/card-layout.html #}

{% macro card_layout(
    title,
    intent="content",
    size="md",
    show_header=true,
    show_footer=true
) %}
<div class="card-base card-intent-{{ intent }} card-size-{{ size }}">
  {% if show_header and title %}
  <div class="card-header">
    <h3 class="card-title">{{ title }}</h3>
    {% if caller.header is defined %}
      {{ caller.header() }}
    {% endif %}
  </div>
  {% endif %}

  <div class="card-body">
    {{ caller() }}
  </div>

  {% if show_footer and caller.footer is defined %}
  <div class="card-footer">
    {{ caller.footer() }}
  </div>
  {% endif %}
</div>
{% endmacro %}

Usage with callable blocks:

{% from "layouts/card-layout.html" import card_layout %}

{% call card_layout(title="My Card", intent="feature") %}
  <p>This is the main content of the card.</p>
  <p>It can be multiple paragraphs.</p>

  {% call (header=true) %}
    <span class="badge-base badge-intent-info">New</span>
  {% endcall %}

  {% call (footer=true) %}
    <button class="btn-base btn-intent-primary">Action</button>
  {% endcall %}
{% endcall %}

Responsive Components

Mobile-First Approach

@layer components {
  /* Mobile (default) */
  .portfolio-grid {
    display: grid;
    grid-template-columns: 1fr;
    gap: var(--spacing-md);
  }

  .portfolio-card-title {
    font-size: var(--text-lg);
  }

  /* Tablet */
  @media (min-width: 640px) {
    .portfolio-grid {
      grid-template-columns: repeat(2, 1fr);
      gap: var(--spacing-lg);
    }

    .portfolio-card-title {
      font-size: var(--text-xl);
    }
  }

  /* Desktop */
  @media (min-width: 1024px) {
    .portfolio-grid {
      grid-template-columns: repeat(3, 1fr);
      gap: var(--spacing-xl);
    }

    .portfolio-card-title {
      font-size: var(--text-2xl);
    }
  }
}

Responsive Variants

@layer components {
  /* Stack on mobile, row on desktop */
  .feature-card {
    display: flex;
    flex-direction: column;
    gap: var(--spacing-md);
  }

  @media (min-width: 768px) {
    .feature-card {
      flex-direction: row;
      align-items: centre;
    }

    .feature-card-image {
      width: 40%;
    }

    .feature-card-content {
      width: 60%;
    }
  }
}

Accessibility

Semantic HTML

{% macro accessible_card(title, content, role="article") %}
<article class="card-base"
         role="{{ role }}"
         {% if title %}aria-labelledby="card-title-{{ loop.index }}"{% endif %}>
  {% if title %}
  <div class="card-header">
    <h3 class="card-title" id="card-title-{{ loop.index }}">
      {{ title }}
    </h3>
  </div>
  {% endif %}

  <div class="card-body">
    {{ content }}
  </div>
</article>
{% endmacro %}

Focus States

@layer components {
  .portfolio-card-base:focus-within {
    outline: 2px solid var(--color-primary);
    outline-offset: 2px;
  }

  .portfolio-card-title a:focus {
    outline: 2px solid var(--color-primary);
    outline-offset: 2px;
    text-decoration: underline;
  }
}

ARIA Labels

{% macro button_with_icon(text, icon, label=none) %}
<button class="btn-base btn-intent-primary btn-icon-left"
        aria-label="{{ label or text }}">
  <svg aria-hidden="true">{{ icon }}</svg>
  <span>{{ text }}</span>
</button>
{% endmacro %}

Testing Components

Visual Testing

<!-- components/tests/visual-test.html -->
<div class="p-8 space-y-8">
  <h2>Component Visual Tests</h2>

  <!-- Test all variants -->
  <section>
    <h3>Size Variants</h3>
    <div class="flex gap-4">
      {{ project_card(sample_project, size="sm") }}
      {{ project_card(sample_project, size="md") }}
      {{ project_card(sample_project, size="lg") }}
    </div>
  </section>

  <section>
    <h3>Intent Variants</h3>
    <div class="flex gap-4">
      {{ project_card(sample_project, intent="default") }}
      {{ project_card(sample_project, intent="featured") }}
      {{ project_card(sample_project, intent="client") }}
    </div>
  </section>

  <section>
    <h3>Dark Mode</h3>
    <div class="dark p-4 bg-slate-900">
      {{ project_card(sample_project) }}
    </div>
  </section>
</section>

Component Documentation

{# components/project-card.html #}

{#
 # Portfolio Project Card Component
 #
 # Displays a portfolio project with image, title, description, and tags
 #
 # @param project: Project object (required)
 #   - project.title: Project title (required)
 #   - project.description: Project description (optional)
 #   - project.image: Project image URL (optional)
 #   - project.tags: Array of tag strings (optional)
 #   - project.url: Project URL (optional)
 #
 # @param size: Card size - "sm", "md", "lg" (default: "md")
 # @param intent: Card intent - "default", "featured", "client" (default: "default")
 # @param show_image: Display project image (default: true)
 # @param show_description: Display project description (default: true)
 # @param show_tags: Display project tags (default: true)
 # @param show_link: Make card clickable if project.url exists (default: true)
 # @param max_tags: Maximum number of tags to display (default: none - show all)
 #
 # Example usage:
 #   {{ project_card(project) }}
 #   {{ project_card(project, size="lg", intent="featured") }}
 #   {{ project_card(project, show_image=false, max_tags=3) }}
 #}
{% macro project_card(...) %}
  <!-- Component code -->
{% endmacro %}

Best Practices

CSS Component Guidelines

Do:

  • ✓ Use semantic class names
  • ✓ Prefix with mixin name
  • ✓ Use CSS custom properties
  • ✓ Include dark mode variants
  • ✓ Follow BEM-style modifiers
  • ✓ Keep components focused

Don't:

  • ✗ Hardcode values
  • ✗ Use overly specific selectors
  • ✗ Create generic names without prefix
  • ✗ Duplicate entire components for variants
  • ✗ Break accessibility

Macro Component Guidelines

Do:

  • ✓ Provide sensible defaults
  • ✓ Make parameters optional
  • ✓ Document parameters
  • ✓ Use semantic HTML
  • ✓ Include ARIA labels
  • ✓ Add with context for Flask functions

Don't:

  • ✗ Create macros for simple CSS
  • ✗ Embed complex logic
  • ✗ Hardcode URLs or values
  • ✗ Create deep nesting
  • ✗ Forget error handling

Next Steps

You now understand how to build comprehensive components. For more information:

Summary

Key principles:

  1. Semantic CSS first - Build with meaningful classes
  2. Macros for logic - Add macros when needed for configuration
  3. Component composition - Combine small pieces
  4. Accessibility always - Semantic HTML, ARIA, focus states
  5. Test thoroughly - Visual tests, dark mode, responsive
  6. Document well - Clear parameter descriptions

With these practices, you'll build maintainable, reusable components that work across your entire site.