All skills
smnandre avatar

/twig-component

@6d7ef8e

Symfony UX TwigComponent for reusable UI building blocks -- server-rendered components with PHP classes and Twig templates. Use when creating buttons, cards, alerts, badges, navbars, or any reusable UI element with props, blocks/slots, computed properties, or anonymous (template-only) components. Code triggers: AsTwigComponent, #[AsTwigComponent], ExposeInTemplate, PreMount, PostMount, <twig:Alert />, <twig:Button>, component(), computed properties, anonymous component, HTML syntax. Also trigger when the user asks "how to create a reusable component", "how to make a component library", "how to pass props to a component", "how to use slots/blocks in a component", "how to build a design system in Symfony", "what is the HTML syntax for components", "how to create a component without a PHP class". Do NOT trigger for components that re-render dynamically on user input (use live-component), for JS behavior (use stimulus), or for page navigation (use turbo).

Use this Skill: https://skilld.dev/gh/smnandre/symfony-ux-skills/twig-component

This session only. Nothing lands on disk.

referencespatterns.md

≈3k tokens on demand. Your agent reads this file only when SKILL.md points to it.

TwigComponent Patterns

Common patterns for building reusable components.


Alert Component

#[AsTwigComponent]
final class Alert
{
    public string $type = 'info';
    public ?string $title = null;
    public bool $dismissible = false;
    
    public function getIcon(): string
    {
        return match ($this->type) {
            'success' => 'check-circle',
            'danger', 'error' => 'x-circle',
            'warning' => 'alert-triangle',
            default => 'info',
        };
    }
}
{# templates/components/Alert.html.twig #}
<div 
    class="alert alert-{{ type }}"
    role="alert"
    {{ attributes }}
>
    <twig:Icon :name="this.icon" />
    
    {% if title %}
        <strong>{{ title }}</strong>
    {% endif %}
    
    <div class="alert-content">
        {% block content %}{% endblock %}
    </div>
    
    {% if dismissible %}
        <button type="button" class="close" data-action="click->alert#dismiss">
            &times;
        </button>
    {% endif %}
</div>

Button Component

{# templates/components/Button.html.twig (anonymous) #}
{% props 
    variant = 'primary',
    size = 'md',
    type = 'button',
    href = null,
    disabled = false,
    loading = false,
    icon = null,
    iconPosition = 'left'
%}

{% set tag = href ? 'a' : 'button' %}
{% set classes = 'btn btn-' ~ variant ~ ' btn-' ~ size %}

<{{ tag }}
    {% if href %}href="{{ href }}"{% endif %}
    {% if not href %}type="{{ type }}"{% endif %}
    class="{{ classes }}"
    {{ disabled or loading ? 'disabled' }}
    {{ attributes }}
>
    {% if loading %}
        <span class="spinner"></span>
    {% elseif icon and iconPosition == 'left' %}
        <twig:Icon :name="icon" />
    {% endif %}
    
    {% block content %}{% endblock %}
    
    {% if icon and iconPosition == 'right' and not loading %}
        <twig:Icon :name="icon" />
    {% endif %}
</{{ tag }}>

Card Component

#[AsTwigComponent]
final class Card
{
    public ?string $title = null;
    public ?string $subtitle = null;
    public ?string $image = null;
    public bool $shadow = true;
    public bool $hover = false;
}
{# templates/components/Card.html.twig #}
<article 
    class="card {{ shadow ? 'shadow' }} {{ hover ? 'card-hover' }}"
    {{ attributes }}
>
    {% if image %}
        <img src="{{ image }}" class="card-image" alt="">
    {% endif %}
    
    {% block header %}
        {% if title or subtitle %}
            <header class="card-header">
                {% if title %}<h3 class="card-title">{{ title }}</h3>{% endif %}
                {% if subtitle %}<p class="card-subtitle">{{ subtitle }}</p>{% endif %}
            </header>
        {% endif %}
    {% endblock %}
    
    <div class="card-body">
        {% block content %}{% endblock %}
    </div>
    
    {% if block('footer') is not empty %}
        <footer class="card-footer">
            {% block footer %}{% endblock %}
        </footer>
    {% endif %}
</article>

Modal Component

#[AsTwigComponent]
final class Modal
{
    public string $id;
    public ?string $title = null;
    public string $size = 'md';
    public bool $closeButton = true;
    public bool $backdrop = true;
}
{# templates/components/Modal.html.twig #}
<dialog 
    id="{{ id }}"
    class="modal modal-{{ size }}"
    {{ not backdrop ? 'data-no-backdrop' }}
    {{ attributes }}
>
    <div class="modal-content">
        <header class="modal-header">
            {% block header %}
                {% if title %}
                    <h2 class="modal-title">{{ title }}</h2>
                {% endif %}
            {% endblock %}
            
            {% if closeButton %}
                <button type="button" class="modal-close" data-action="click->modal#close">
                    &times;
                </button>
            {% endif %}
        </header>
        
        <div class="modal-body">
            {% block content %}{% endblock %}
        </div>
        
        {% if block('footer') is not empty %}
            <footer class="modal-footer">
                {% block footer %}{% endblock %}
            </footer>
        {% endif %}
    </div>
</dialog>

Form Field Component

{# templates/components/FormField.html.twig (anonymous) #}
{% props 
    name,
    label = null,
    type = 'text',
    value = '',
    placeholder = '',
    required = false,
    error = null,
    help = null
%}

<div class="form-field {{ error ? 'has-error' }}" {{ attributes }}>
    {% if label %}
        <label for="{{ name }}" class="form-label">
            {{ label }}
            {% if required %}<span class="required">*</span>{% endif %}
        </label>
    {% endif %}
    
    {% block input %}
        <input 
            type="{{ type }}"
            id="{{ name }}"
            name="{{ name }}"
            value="{{ value }}"
            placeholder="{{ placeholder }}"
            {{ required ? 'required' }}
            class="form-input"
        >
    {% endblock %}
    
    {% if error %}
        <p class="form-error">{{ error }}</p>
    {% elseif help %}
        <p class="form-help">{{ help }}</p>
    {% endif %}
</div>

Table Component

#[AsTwigComponent]
final class DataTable
{
    public array $columns;
    public array $rows;
    public ?string $emptyMessage = 'No data available';
    public bool $striped = true;
    public bool $hoverable = true;
    
    public function isEmpty(): bool
    {
        return count($this->rows) === 0;
    }
}
{# templates/components/DataTable.html.twig #}
<div class="table-container" {{ attributes }}>
    <table class="table {{ striped ? 'table-striped' }} {{ hoverable ? 'table-hover' }}">
        <thead>
            <tr>
                {% for column in columns %}
                    <th>{{ column.label ?? column }}</th>
                {% endfor %}
                {% block extra_headers %}{% endblock %}
            </tr>
        </thead>
        <tbody>
            {% if this.empty %}
                <tr>
                    <td colspan="{{ columns|length }}">
                        {% block empty %}
                            {{ emptyMessage }}
                        {% endblock %}
                    </td>
                </tr>
            {% else %}
                {% for row in rows %}
                    <tr>
                        {% for column in columns %}
                            <td>{{ attribute(row, column.key ?? column) }}</td>
                        {% endfor %}
                        {% block row_actions %}{% endblock %}
                    </tr>
                {% endfor %}
            {% endif %}
        </tbody>
    </table>
</div>

Dropdown Component

{# templates/components/Dropdown.html.twig (anonymous) #}
{% props 
    label,
    align = 'left',
    variant = 'default'
%}

<div 
    class="dropdown"
    data-controller="dropdown"
    {{ attributes }}
>
    <button 
        type="button"
        class="dropdown-toggle btn btn-{{ variant }}"
        data-action="click->dropdown#toggle"
    >
        {{ label }}
        <span class="caret"></span>
    </button>
    
    <div class="dropdown-menu dropdown-{{ align }}" data-dropdown-target="menu" hidden>
        {% block content %}{% endblock %}
    </div>
</div>

Pagination Component

#[AsTwigComponent]
final class Pagination
{
    public int $currentPage;
    public int $totalPages;
    public int $visiblePages = 5;
    public string $routeName;
    public array $routeParams = [];
    
    public function getPages(): array
    {
        $start = max(1, $this->currentPage - floor($this->visiblePages / 2));
        $end = min($this->totalPages, $start + $this->visiblePages - 1);
        $start = max(1, $end - $this->visiblePages + 1);
        
        return range($start, $end);
    }
    
    public function hasPrevious(): bool
    {
        return $this->currentPage > 1;
    }
    
    public function hasNext(): bool
    {
        return $this->currentPage < $this->totalPages;
    }
}
{# templates/components/Pagination.html.twig #}
{% if totalPages > 1 %}
<nav class="pagination" {{ attributes }}>
    <a 
        href="{{ this.hasPrevious ? path(routeName, routeParams|merge({page: currentPage - 1})) : '#' }}"
        class="pagination-prev {{ not this.hasPrevious ? 'disabled' }}"
    >
        Previous
    </a>
    
    {% for page in this.pages %}
        <a 
            href="{{ path(routeName, routeParams|merge({page: page})) }}"
            class="pagination-page {{ page == currentPage ? 'active' }}"
        >
            {{ page }}
        </a>
    {% endfor %}
    
    <a 
        href="{{ this.hasNext ? path(routeName, routeParams|merge({page: currentPage + 1})) : '#' }}"
        class="pagination-next {{ not this.hasNext ? 'disabled' }}"
    >
        Next
    </a>
</nav>
{% endif %}

Tabs Component

{# templates/components/Tabs.html.twig (anonymous) #}
{% props tabs, activeTab = null %}

{% set activeTab = activeTab ?? tabs|keys|first %}

<div class="tabs" data-controller="tabs" {{ attributes }}>
    <div class="tabs-nav" role="tablist">
        {% for key, tab in tabs %}
            <button 
                type="button"
                role="tab"
                class="tab-button {{ key == activeTab ? 'active' }}"
                data-tabs-target="tab"
                data-action="click->tabs#select"
                data-key="{{ key }}"
            >
                {{ tab.label ?? key }}
            </button>
        {% endfor %}
    </div>
    
    <div class="tabs-content">
        {% for key, tab in tabs %}
            <div 
                role="tabpanel"
                class="tab-panel"
                data-tabs-target="panel"
                {{ key != activeTab ? 'hidden' }}
            >
                {% block tab_content %}
                    {{ tab.content|raw }}
                {% endblock %}
            </div>
        {% endfor %}
    </div>
</div>

Icon Component

{# templates/components/Icon.html.twig (anonymous) #}
{% props name, size = 'md', class = '' %}

<svg 
    class="icon icon-{{ size }} {{ class }}"
    {{ attributes.without('class') }}
>
    <use href="#icon-{{ name }}"></use>
</svg>

Avatar Component

{# templates/components/Avatar.html.twig (anonymous) #}
{% props 
    src = null,
    alt = '',
    initials = null,
    size = 'md'
%}

<div class="avatar avatar-{{ size }}" {{ attributes }}>
    {% if src %}
        <img src="{{ src }}" alt="{{ alt }}" class="avatar-image">
    {% elseif initials %}
        <span class="avatar-initials">{{ initials }}</span>
    {% else %}
        <span class="avatar-placeholder">
            <twig:Icon name="user" />
        </span>
    {% endif %}
</div>

Composition Example

Combining multiple components:

<twig:Card title="Team Members" shadow>
    <twig:block name="header">
        <div class="flex justify-between">
            <h3>Team Members</h3>
            <twig:Button variant="primary" size="sm" icon="plus">
                Add Member
            </twig:Button>
        </div>
    </twig:block>
    
    <twig:DataTable 
        :columns="['Name', 'Role', 'Status']"
        :rows="members"
    >
        <twig:block name="empty">
            <twig:Alert type="info">
                No team members yet. Add your first member!
            </twig:Alert>
        </twig:block>
        
        <twig:block name="row_actions">
            <td>
                <twig:Dropdown label="Actions" variant="ghost">
                    <a href="#">Edit</a>
                    <a href="#" class="text-danger">Remove</a>
                </twig:Dropdown>
            </td>
        </twig:block>
    </twig:DataTable>
    
    <twig:block name="footer">
        <twig:Pagination 
            :currentPage="page"
            :totalPages="totalPages"
            routeName="team_members"
        />
    </twig:block>
</twig:Card>

Source: SKILL.md on GitHub

No alerts16d4 checks · Risk SAFE
  • Gen Agent Trust Hub16d

    This skill provides comprehensive documentation and implementation patterns for the Symfony UX TwigComponent library. It contains legitimate code snippets, API references, and configuration guides. No malicious behavior or security risks were identified.

  • Socket16d

    No alerts

  • Snyk16d

    Risk: LOW · No issues

  • Runlayer7mo

    3 files scanned · No issues

Signed by skilld at 6d7ef8e. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 months ago.

Steadyupdated 4 months ago
Other metadata
metadata
{
  "author": "Simon Andre",
  "email": "smn.andre@gmail.com",
  "url": "https://smnandre.dev",
  "version": "1.2.0"
}

README badge

README badge for smnandre/symfony-ux-skills/twig-component