HtmxResponseBuilder Showcase

Interactive demo of all HtmxResponseBuilder methods. Click buttons to see each method in action.

Trigger Events

Fire JavaScript events from server response

Click button to see trigger event result
URL Manipulation

Change browser URL from server (watch address bar)

Retarget & Reselect

Server controls where content goes

Original target
Retarget area
Reselect area
Reswap & Modifiers

Control how content is swapped

Transition
Timing
Success & Failure Status

HTTP status codes: 200 vs 422

Click to see HTTP status difference
Multiple OOB & No Content

Advanced response patterns

Area 1
Area 2
Area 3
204 result
Activity Log

Click buttons to see methods in action

Source Code View on GitHub
<?php

declare(strict_types=1);

namespace App\Controller;

use Mdxpl\HtmxBundle\Attribute\HtmxOnly;
use Mdxpl\HtmxBundle\Request\HtmxRequest;
use Mdxpl\HtmxBundle\Response\HtmxResponse;
use Mdxpl\HtmxBundle\Response\HtmxResponseBuilder;
use Mdxpl\HtmxBundle\Response\Swap\Modifiers\TimingSwap;
use Mdxpl\HtmxBundle\Response\Swap\Modifiers\Transition;
use Mdxpl\HtmxBundle\Response\Swap\SwapStyle;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Attribute\Route;

#[Route('/builder-showcase')]
final class BuilderShowcaseController extends AbstractController
{
    #[Route('', name: 'app_builder_showcase')]
    public function index(HtmxRequest $htmx): HtmxResponse
    {
        return HtmxResponseBuilder::create($htmx->isHtmx)
            ->success()
            ->view('builder_showcase.html.twig', [
                'logs' => [],
                'counter' => 0,
                'items' => [],
            ])
            ->build();
    }

    /**
     * Demonstrates: trigger() - fires JS event immediately
     */
    #[Route('/trigger', name: 'app_builder_showcase_trigger')]
    #[HtmxOnly]
    public function trigger(HtmxRequest $htmx): HtmxResponse
    {
        return HtmxResponseBuilder::create($htmx->isHtmx)
            ->success()
            ->viewBlock('builder_showcase.html.twig', 'triggerResultOob', [
                'triggerType' => 'trigger()',
                'message' => 'Event fired IMMEDIATELY when response received',
            ])
            ->viewBlock('builder_showcase.html.twig', 'logEntryOob', [
                'log' => ['method' => 'trigger()', 'description' => 'HX-Trigger header sent, event fires immediately'],
            ])
            ->trigger('demoEvent')
            ->build();
    }

    /**
     * Demonstrates: triggerAfterSwap() - fires after DOM swap
     */
    #[Route('/trigger-after-swap', name: 'app_builder_showcase_trigger_after_swap')]
    #[HtmxOnly]
    public function triggerAfterSwap(HtmxRequest $htmx): HtmxResponse
    {
        return HtmxResponseBuilder::create($htmx->isHtmx)
            ->success()
            ->viewBlock('builder_showcase.html.twig', 'triggerResultOob', [
                'triggerType' => 'triggerAfterSwap()',
                'message' => 'Event fired AFTER content swapped into DOM',
            ])
            ->viewBlock('builder_showcase.html.twig', 'logEntryOob', [
                'log' => ['method' => 'triggerAfterSwap()', 'description' => 'HX-Trigger-After-Swap header sent'],
            ])
            ->triggerAfterSwap('demoEvent')
            ->build();
    }

    /**
     * Demonstrates: triggerAfterSettle() - fires after CSS transitions
     */
    #[Route('/trigger-after-settle', name: 'app_builder_showcase_trigger_after_settle')]
    #[HtmxOnly]
    public function triggerAfterSettle(HtmxRequest $htmx): HtmxResponse
    {
        return HtmxResponseBuilder::create($htmx->isHtmx)
            ->success()
            ->viewBlock('builder_showcase.html.twig', 'triggerResultOob', [
                'triggerType' => 'triggerAfterSettle()',
                'message' => 'Event fired AFTER CSS transitions completed',
            ])
            ->viewBlock('builder_showcase.html.twig', 'logEntryOob', [
                'log' => ['method' => 'triggerAfterSettle()', 'description' => 'HX-Trigger-After-Settle header sent'],
            ])
            ->triggerAfterSettle('demoEvent')
            ->build();
    }

    /**
     * Demonstrates: success() - HTTP 200 OK
     */
    #[Route('/success', name: 'app_builder_showcase_success')]
    #[HtmxOnly]
    public function success(HtmxRequest $htmx): HtmxResponse
    {
        return HtmxResponseBuilder::create($htmx->isHtmx)
            ->success()
            ->viewBlock('builder_showcase.html.twig', 'successResult')
            ->viewBlock('builder_showcase.html.twig', 'logEntryOob', [
                'log' => ['method' => 'success()', 'description' => 'HTTP 200 OK - standard successful response'],
            ])
            ->build();
    }

    /**
     * Demonstrates: failure() - HTTP 422 Unprocessable Entity
     */
    #[Route('/failure', name: 'app_builder_showcase_failure')]
    #[HtmxOnly]
    public function failure(HtmxRequest $htmx): HtmxResponse
    {
        return HtmxResponseBuilder::create($htmx->isHtmx)
            ->failure()
            ->viewBlock('builder_showcase.html.twig', 'failureResult')
            ->viewBlock('builder_showcase.html.twig', 'logEntryOob', [
                'log' => ['method' => 'failure()', 'description' => 'HTTP 422 - htmx swaps content thanks to beforeOnLoad handler'],
            ])
            ->build();
    }

    /**
     * Demonstrates: pushUrl() - adds URL to browser history
     */
    #[Route('/push-url', name: 'app_builder_showcase_push_url')]
    #[HtmxOnly]
    public function pushUrl(HtmxRequest $htmx): HtmxResponse
    {
        $newUrl = '/builder-showcase?demo=push-url&time=' . time();

        return HtmxResponseBuilder::create($htmx->isHtmx)
            ->success()
            ->viewBlock('builder_showcase.html.twig', 'logEntryOob', [
                'log' => ['method' => 'pushUrl()', 'description' => "URL changed to: {$newUrl} (check address bar, use back button)"],
            ])
            ->pushUrl($newUrl)
            ->build();
    }

    /**
     * Demonstrates: replaceUrl() - replaces URL without history entry
     */
    #[Route('/replace-url', name: 'app_builder_showcase_replace_url')]
    #[HtmxOnly]
    public function replaceUrl(HtmxRequest $htmx): HtmxResponse
    {
        $newUrl = '/builder-showcase?replaced=' . time();

        return HtmxResponseBuilder::create($htmx->isHtmx)
            ->success()
            ->viewBlock('builder_showcase.html.twig', 'logEntryOob', [
                'log' => ['method' => 'replaceUrl()', 'description' => "URL replaced (no history entry, back won't return here)"],
            ])
            ->replaceUrl($newUrl)
            ->build();
    }

    /**
     * Demonstrates: retarget() - server changes the swap target
     */
    #[Route('/retarget', name: 'app_builder_showcase_retarget')]
    #[HtmxOnly]
    public function retarget(HtmxRequest $htmx): HtmxResponse
    {
        return HtmxResponseBuilder::create($htmx->isHtmx)
            ->success()
            ->viewBlock('builder_showcase.html.twig', 'retargetContent')
            ->viewBlock('builder_showcase.html.twig', 'logEntryOob', [
                'log' => ['method' => 'retarget()', 'description' => 'Button targeted #original-target, server redirected to #retarget-area'],
            ])
            ->retarget('#retarget-area')
            ->build();
    }

    /**
     * Demonstrates: withReswap() with beforeend - append to list
     */
    #[Route('/reswap-append', name: 'app_builder_showcase_reswap_append')]
    #[HtmxOnly]
    public function reswapAppend(HtmxRequest $htmx): HtmxResponse
    {
        $itemId = time();

        return HtmxResponseBuilder::create($htmx->isHtmx)
            ->success()
            ->viewBlock('builder_showcase.html.twig', 'newItem', ['itemId' => $itemId])
            ->viewBlock('builder_showcase.html.twig', 'logEntryOob', [
                'log' => ['method' => 'withReswap(BEFORE_END)', 'description' => "Item #{$itemId} appended to list (not replaced)"],
            ])
            ->withReswap(SwapStyle::BEFORE_END)
            ->build();
    }

    /**
     * Demonstrates: withReswap() with Transition modifier
     */
    #[Route('/reswap-transition', name: 'app_builder_showcase_reswap_transition')]
    #[HtmxOnly]
    public function reswapTransition(HtmxRequest $htmx): HtmxResponse
    {
        return HtmxResponseBuilder::create($htmx->isHtmx)
            ->success()
            ->viewBlock('builder_showcase.html.twig', 'transitionContent')
            ->viewBlock('builder_showcase.html.twig', 'logEntryOob', [
                'log' => ['method' => 'withReswap() + Transition', 'description' => 'Content swapped with View Transition API animation'],
            ])
            ->retarget('#transition-area')
            ->withReswap(SwapStyle::INNER_HTML, new Transition())
            ->build();
    }

    /**
     * Demonstrates: withReswap() with TimingSwap modifier
     */
    #[Route('/reswap-timing', name: 'app_builder_showcase_reswap_timing')]
    #[HtmxOnly]
    public function reswapTiming(HtmxRequest $htmx): HtmxResponse
    {
        return HtmxResponseBuilder::create($htmx->isHtmx)
            ->success()
            ->viewBlock('builder_showcase.html.twig', 'timingContent')
            ->viewBlock('builder_showcase.html.twig', 'logEntryOob', [
                'log' => ['method' => 'withReswap() + TimingSwap(1000)', 'description' => 'Swap delayed by 1 second'],
            ])
            ->retarget('#timing-area')
            ->withReswap(SwapStyle::INNER_HTML, new TimingSwap(1000))
            ->build();
    }

    /**
     * Demonstrates: noContent() - 204 response, only headers processed
     */
    #[Route('/no-content', name: 'app_builder_showcase_no_content')]
    #[HtmxOnly]
    public function noContent(HtmxRequest $htmx): HtmxResponse
    {
        return HtmxResponseBuilder::create($htmx->isHtmx)
            ->noContent()
            ->trigger(['noContentDemo' => ['message' => 'HTTP 204 - no body, but trigger header works!']])
            ->build();
    }

    /**
     * Demonstrates: reselect() - server selects part of response
     */
    #[Route('/reselect', name: 'app_builder_showcase_reselect')]
    #[HtmxOnly]
    public function reselect(HtmxRequest $htmx): HtmxResponse
    {
        return HtmxResponseBuilder::create($htmx->isHtmx)
            ->success()
            ->viewBlock('builder_showcase.html.twig', 'reselectFullResponse')
            ->viewBlock('builder_showcase.html.twig', 'logEntryOob', [
                'log' => ['method' => 'reselect()', 'description' => 'Full response sent, but only #selected-part was swapped'],
            ])
            ->retarget('#reselect-area')
            ->reselect('#selected-part')
            ->build();
    }

    /**
     * Demonstrates: multiple OOB updates in single response
     */
    #[Route('/multiple-oob', name: 'app_builder_showcase_multiple_oob')]
    #[HtmxOnly]
    public function multipleOob(HtmxRequest $htmx): HtmxResponse
    {
        return HtmxResponseBuilder::create($htmx->isHtmx)
            ->success()
            ->viewBlock('builder_showcase.html.twig', 'oobArea1')
            ->viewBlock('builder_showcase.html.twig', 'oobArea2')
            ->viewBlock('builder_showcase.html.twig', 'oobArea3')
            ->viewBlock('builder_showcase.html.twig', 'logEntryOob', [
                'log' => ['method' => 'Multiple viewBlock()', 'description' => '3 different areas updated with single request'],
            ])
            ->build();
    }

    /**
     * Demonstrates: location() - client-side navigation without full reload
     */
    #[Route('/location', name: 'app_builder_showcase_location')]
    #[HtmxOnly]
    public function location(HtmxRequest $htmx): HtmxResponse
    {
        return HtmxResponseBuilder::create($htmx->isHtmx)
            ->success()
            ->location($this->generateUrl('app_builder_showcase') . '?from=location')
            ->build();
    }

    /**
     * Clears the log
     */
    #[Route('/clear-log', name: 'app_builder_showcase_clear_log')]
    #[HtmxOnly]
    public function clearLog(HtmxRequest $htmx): HtmxResponse
    {
        return HtmxResponseBuilder::create($htmx->isHtmx)
            ->success()
            ->viewBlock('builder_showcase.html.twig', 'logListOob', ['logs' => []])
            ->build();
    }
}
{% block logEntryOob %}
<div id="log-list" hx-swap-oob="afterbegin">
    <div class="log-entry">
        <div class="log-method">{{ log.method }}</div>
        <div class="log-desc">{{ log.description }}</div>
    </div>
</div>
{% endblock %}

{% block logListOob %}
<div id="log-list" hx-swap-oob="true" style="max-height: 400px; overflow-y: auto;">
    {% if logs|length > 0 %}
        {% for log in logs %}
        <div class="log-entry">
            <div class="log-method">{{ log.method }}</div>
            <div class="log-desc">{{ log.description }}</div>
        </div>
        {% endfor %}
    {% else %}
        <p><small>Log cleared</small></p>
    {% endif %}
</div>
{% endblock %}

{% block retargetContent %}
<div style="color: var(--pico-ins-color); font-weight: bold;">✓ Content arrived here via retarget()!</div>
{% endblock %}

{% block newItem %}
<kbd style="margin-right: 0.25rem;">Item #{{ itemId }}</kbd>
{% endblock %}

{% block transitionContent %}
<div style="color: var(--pico-mark-background-color); font-weight: bold;">✨ Transitioned! ({{ "now"|date("H:i:s") }})</div>
{% endblock %}

{% block timingContent %}
<div style="color: var(--pico-del-color); font-weight: bold;">⏱️ Delayed 1s! ({{ "now"|date("H:i:s") }})</div>
{% endblock %}

{% block reselectFullResponse %}
<div id="ignored-part" style="color: var(--pico-del-color);">This part is ignored</div>
<div id="selected-part" style="color: var(--pico-ins-color); font-weight: bold;">✓ Only this part was selected!</div>
<div id="also-ignored" style="color: var(--pico-del-color);">This is also ignored</div>
{% endblock %}

{% block oobArea1 %}
<div id="oob-area-1" hx-swap-oob="true" class="result-box" style="background: rgba(54, 211, 153, 0.2);">Updated 1! ✓</div>
{% endblock %}

{% block oobArea2 %}
<div id="oob-area-2" hx-swap-oob="true" class="result-box" style="background: rgba(251, 189, 35, 0.2);">Updated 2! ✓</div>
{% endblock %}

{% block oobArea3 %}
<div id="oob-area-3" hx-swap-oob="true" class="result-box" style="background: rgba(102, 26, 230, 0.2);">Updated 3! ✓</div>
{% endblock %}

{% block successResult %}
<div style="background: var(--pico-ins-color); color: #fff; padding: 1rem; border-radius: var(--pico-border-radius);">
    <strong>HTTP 200 OK</strong><br>
    <small>success() - standard successful response</small>
</div>
{% endblock %}

{% block failureResult %}
<div style="background: var(--pico-del-color); color: #fff; padding: 1rem; border-radius: var(--pico-border-radius);">
    <strong>HTTP 422 Unprocessable Entity</strong><br>
    <small>failure() - validation errors (htmx still swaps)</small>
</div>
{% endblock %}

{% block triggerResultOob %}
<div id="trigger-result" hx-swap-oob="true" class="result-box" style="background: rgba(54, 211, 153, 0.2);">
    <strong style="color: var(--pico-ins-color);">{{ triggerType }}:</strong> {{ message }} <small>({{ "now"|date("H:i:s") }})</small>
</div>
{% endblock %}