Skip to content

[RFC] Theme Support for Symfony UX Twig Components #3050

@devnix

Description

@devnix

Problem Statement

Currently, Symfony UX Twig Components don't support theme switching for different UI frameworks (Bootstrap, Tailwind, Bulma, etc.). Developers need to manually create separate components or use complex conditional logic within templates.

Proposed Solution

Warning

I've used the help of Claude AI to help me draft code suggestions, so bear in mind that these are not specific suggestions since I still don't have a deep understanding of Twig Components internals

1. Core Components

// src/Attribute/WithTheme.php
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
class WithTheme
{
    public function __construct(
        public readonly string $name,
        public readonly int $priority = 0
    ) {}
}

// src/Theme/ComponentThemeRegistry.php
class ComponentThemeRegistry
{
    private array $themes = [];
    private ?string $activeTheme = null;
    
    public function registerTheme(string $name, array $components): void
    {
        $this->themes[$name] = $components;
    }
    
    public function setActiveTheme(string $theme): void
    {
        $this->activeTheme = $theme;
    }
    
    public function resolveComponent(string $componentName): string
    {
        if (!$this->activeTheme) {
            return $componentName;
        }
        
        return $this->themes[$this->activeTheme][$componentName] ?? $componentName;
    }
}

// src/Theme/ThemeAwareComponentRenderer.php
class ThemeAwareComponentRenderer extends ComponentRenderer
{
    public function __construct(
        private ComponentThemeRegistry $themeRegistry,
        // ... other dependencies
    ) {}
    
    public function createComponent(string $name, array $props = []): ComponentInterface
    {
        $themedName = $this->themeRegistry->resolveComponent($name);
        return parent::createComponent($themedName, $props);
    }
}

2. Twig Extensions

// src/Twig/ComponentThemeExtension.php
class ComponentThemeExtension extends AbstractExtension
{
    public function __construct(
        private ComponentThemeRegistry $themeRegistry
    ) {}
    
    public function getTokenParsers(): array
    {
        return [
            new ComponentThemeTokenParser(),
        ];
    }
    
    public function getFunctions(): array
    {
        return [
            new TwigFunction('component_theme', [$this->themeRegistry, 'setActiveTheme']),
            new TwigFunction('themed_component', [$this, 'renderThemedComponent']),
        ];
    }
}

// src/Twig/ComponentThemeTokenParser.php
class ComponentThemeTokenParser extends AbstractTokenParser
{
    public function parse(Token $token): Node
    {
        $lineno = $token->getLine();
        $stream = $this->parser->getStream();
        
        $theme = $stream->expect(Token::STRING_TYPE)->getValue();
        $stream->expect(Token::BLOCK_END_TYPE);
        
        $body = $this->parser->subparse([$this, 'decideBlockEnd'], true);
        $stream->expect(Token::BLOCK_END_TYPE);
        
        return new ComponentThemeNode($theme, $body, $lineno, $this->getTag());
    }
    
    public function getTag(): string
    {
        return 'component_theme';
    }
}

3. Configuration

# config/packages/twig_component.yaml
twig_component:
    themes:
        bootstrap_5:
            - 'components-theme/bootstrap_5'
        tailwind:
            - 'components-theme/tailwind'
    default_theme: 'bootstrap_5'
    # if it doesn't find a template, still search it in `defaults`

4. Usage Examples

{# Template-level theme switching #}
{% component_theme 'tailwind' %}
    {{ component('alert', {message: 'Hello'}) }}
    {{ component('button', {text: 'Submit'}) }}
{% endcomponent_theme %}

{# Function-based approach #}
{{ themed_component('alert', 'bootstrap_5', {message: 'Hello'}) }}
// Controller-level theme switching
#[WithTheme('bootstrap_5')]
class UserController extends AbstractController
{
    public function index(): Response
    {
        // All components in this action will use bootstrap_5 theme
        return $this->render('user/index.html.twig');
    }
}

// Runtime theme switching
class UserController extends AbstractController
{
    public function __construct(
        private ComponentThemeRegistry $themeRegistry
    ) {}
    
    public function profile(): Response
    {
        return $this->withComponentTheme('tailwind', function() {
            return $this->render('user/profile.html.twig');
        });
    }
}

Implementation Steps

Phase 1: Core Infrastructure

  1. Create ComponentThemeRegistry service
  2. Add WithTheme attribute
  3. Modify component resolution logic
  4. Add basic Twig functions

Phase 2: Template Integration

  1. Implement {% component_theme %} tag
  2. Add theme-aware component renderer
  3. Create configuration system

Phase 3: Advanced Features

  1. Theme inheritance
  2. Component variants
  3. Runtime switching helpers
  4. Form theme integration

Phase 4: Developer Experience

  1. Debug toolbar integration
  2. Configuration validation
  3. Performance optimizations
  4. Documentation and examples

Backward Compatibility

  • All existing components work without changes
  • Theme system is opt-in
  • No breaking changes to current APIs
  • Graceful fallback when themes aren't configured

Migration Path

Existing projects can migrate gradually:

  1. Install updated bundle
  2. Configure themes (optional)
  3. Start using theme features incrementally
  4. Existing components continue working unchanged

Performance Considerations

  • Theme resolution cached during request
  • Minimal overhead when themes not used
  • Component registry built once at container compile time
  • No impact on existing non-themed components

I would be eager to try to do a prototype to gather how it should work and contribute with a pull request, I would love to have feedback from maintainers to check the viability and to steer the contribution to the right direction.

Thanks for this awesome package and community <3

Metadata

Metadata

Assignees

No one assigned

    Labels

    RFCRFC = Request For Comments (proposals about features that you want to be discussed)

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions