How to Build a Custom 'Product Quick View' Module in Magento 2

Want to add a fast, polished "Product Quick View" modal to your Magento 2 store? In this post I’ll walk you through building a custom, production-ready Quick View module step-by-step. I’ll explain the technical architecture, show the exact files and code snippets you need, and cover advanced topics like working with Magento caches and indexers, accessibility, responsive design, CSS/JS customization, and testing/debugging tips. Think of this as a friendly walkthrough you can follow with a terminal open and your IDE ready.

Why a custom Product Quick View?

Quick View is an important UX pattern for e-commerce: it lets shoppers preview product details without leaving the category or search page. But if implemented naively it can hurt performance, break caching, or be inaccessible. A custom Magento 2 module gives us full control: we can make it cache-friendly, index-aware, keyboard accessible, and easy to style or extend.

High-level technical architecture

Here’s a practical architecture I use for a performant Quick View:

  • List page (category/search) loads product tiles normally. Each tile contains a lightweight "Quick View" button with a product ID.
  • Clicking the button triggers an AJAX call to a controller that returns fully-rendered product HTML for the modal. The controller is configured for cache friendliness (we’ll discuss cacheable fragments and hole-punching).
  • Frontend JavaScript injects the returned HTML into a modal component and handles keyboard, focus trap, and responsive behavior.
  • Styling handled via scoped CSS (Less or Sass compiled to CSS) and optional theme overrides.
  • All product data access uses Magento repositories, respecting indexers and flat/indexed tables where needed.

Why AJAX? Because category pages can be full-page cached (FPC) and we don't want to break full-page cache when adding dynamic product modals. Loading product content via AJAX lets the category page remain cacheable while still delivering up-to-date product details.

Module skeleton

We’ll create a module called MageFine_QuickView (adjust vendor name to your company). The minimal file structure:

app/code/MageFine/QuickView/
├── etc
│   ├── module.xml
│   └── frontend
│       └── routes.xml
├── registration.php
├── Controller
│   └── Ajax
│       └── Product.php
├── Block
│   └── QuickView.php
├── view
│   └── frontend
│       ├── layout
│       │   └── quickview_ajax_product.xml
│       ├── templates
│       │   └── quickview.phtml
│       └── web
│           ├── js
│           │   └── quickview.js
│           └── css
│               └── quickview.css
└── composer.json (optional)

Registration and module declaration

// app/code/MageFine/QuickView/registration.php

Route and controller

We expose an AJAX endpoint to fetch product HTML. Using a dedicated controller under /quickview/ajax/product makes it easy.


<?xml version="1.0"?>
<routes xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:App/etc/routes.xsd">
    <route id="magefine_quickview" frontName="quickview">
        <module name="MageFine_QuickView" />
    </route>
</routes>

// app/code/MageFine/QuickView/Controller/Ajax/Product.php
productRepository = $productRepository;
        $this->pageFactory = $pageFactory;
        $this->resultRawFactory = $resultRawFactory;
    }

    public function execute()
    {
        $productId = (int)$this->getRequest()->getParam('id');
        if (!$productId) {
            return $this->resultRawFactory->create()->setContents('');
        }

        try {
            // Load product via repository (respects indexers)
            $product = $this->productRepository->getById($productId);

            // Render a small page with our quickview template
            $page = $this->pageFactory->create();
            $page->getConfig()->getTitle()->set($product->getName());

            // Pass product to block (we will map block to template in layout file)
            $page->getLayout()->getBlock('magefine.quickview.block')->setProduct($product);

            // Render layout and return HTML
            $html = $page->getLayout()->getOutput();

            // IMPORTANT: mark response as non-cacheable on Varnish if needed, or set proper headers in frontend
            return $this->resultRawFactory->create()->setContents($html);
        } catch (\Exception $e) {
            // Log and return empty (graceful)
            // Use logger in production; for brevity we skip it here
            return $this->resultRawFactory->create()->setContents('');
        }
    }
}

Note: Above we used ProductRepositoryInterface->getById which is index-friendly. Repository will use normal Magento mechanisms, respecting product visibility and other indexes.

Layout and template

We need a layout that maps to the controller and provides a block with a template:


<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" layout="empty" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
    <body>
        <block class="MageFine\QuickView\Block\QuickView" name="magefine.quickview.block" template="MageFine_QuickView::quickview.phtml" />
    </body>
</page>

// app/code/MageFine/QuickView/Block/QuickView.php
product = $product;
        return $this;
    }

    public function getProduct()
    {
        return $this->product;
    }
}

// app/code/MageFine/QuickView/view/frontend/templates/quickview.phtml

<div class="magefine-quickview" role="dialog" aria-modal="true" aria-label="getProduct()->getName()); ?>">
    <div class="quickview-inner">
        <h2 class="product-name"><?php echo $block->escapeHtml($block->getProduct()->getName()); ?></h2>
        <div class="product-image"><?php // use gallery block or helper to render images ?></div>
        <div class="product-price"><?php echo $block->getProduct()->getPrice(); ?></div>
        <div class="product-short-description"><?php echo $block->getProduct()->getShortDescription(); ?></div>
        <button class="action primary to-cart" data-product-id="<?php echo $block->getProduct()->getId(); ?>">Add to Cart</button>
    </div>
</div>

Frontend JavaScript (modal + AJAX)

Keep the JS small and focused. Use Magento's requirejs setup and UI components if you like. Here’s a simple AMD module to fetch content and show a modal. It also handles keyboard and focus trap basics.

// app/code/MageFine/QuickView/view/frontend/web/js/quickview.js
define(['jquery'], function($) {
    'use strict';
    var QuickView = function(config) {
        this.ajaxUrl = config.ajaxUrl; // /quickview/ajax/product
        this.init();
    };

    QuickView.prototype.init = function() {
        var self = this;
        $(document).on('click', '[data-quickview-id]', function(e) {
            e.preventDefault();
            var id = $(this).data('quickview-id');
            self.open(id);
        });
    };

    QuickView.prototype.open = function(id) {
        var self = this;
        $.ajax({
            url: this.ajaxUrl,
            method: 'GET',
            data: {id: id},
            beforeSend: function() {
                self.showLoading();
            }
        }).done(function(html) {
            self.showModal(html);
        }).fail(function() {
            // handle error silently
        }).always(function() {
            self.hideLoading();
        });
    };

    QuickView.prototype.showModal = function(html) {
        var $modal = $('
', {class: 'magefine-qv-overlay', 'aria-hidden': 'false'}).append( $('
', {class: 'magefine-qv-modal', role: 'dialog', 'aria-modal': 'true'}).append(html) ); $('body').append($modal); this.trapFocus($modal); this.bindClose($modal); }; QuickView.prototype.bindClose = function($modal) { var self = this; $modal.on('click', function(e) { if ($(e.target).is('.magefine-qv-overlay')) { self.closeModal($modal); } }); $(document).on('keydown.mf_qv', function(e) { if (e.key === 'Escape') { self.closeModal($modal); } }); }; QuickView.prototype.closeModal = function($modal) { $modal.remove(); $(document).off('keydown.mf_qv'); // restore focus if needed }; QuickView.prototype.trapFocus = function($modal) { // lightweight focus trap for accessibility; production: use a well-tested library var focusable = 'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), [tabindex]'; var $first = $modal.find(focusable).first(); var $last = $modal.find(focusable).last(); $first.focus(); $modal.on('keydown', function(e) { if (e.key === 'Tab') { if (e.shiftKey && document.activeElement === $first[0]) { e.preventDefault(); $last.focus(); } else if (!e.shiftKey && document.activeElement === $last[0]) { e.preventDefault(); $first.focus(); } } }); }; QuickView.prototype.showLoading = function() { /* show spinner */ }; QuickView.prototype.hideLoading = function() { /* hide spinner */ }; return QuickView; });

Make sure to initialize this script in your theme or module layout using a small inline script or via a Knockout component depending on your stack. Example inline init in your category template or via requirejs-config mapping:

<script>
require(['MageFine_QuickView/js/quickview'], function(QuickView) {
    new QuickView({ajaxUrl: '/quickview/ajax/product'});
});
</script>

Integrating with Magento cache and indexation

This is the key part to get right. You want category pages to be cached by FPC (Varnish or built-in FPC), but Quick View content must reflect the current product state (price, stock, options). Best practice:

  • Keep the category page cacheable by loading the Quick View content via AJAX. The AJAX endpoint can be non-cacheable, or use private content approaches.
  • When you build HTML server-side for the modal, include product cache tags so the block or custom cache can be invalidated when product changes. That way, if you cache the quick view responses, they become invalidated on product save.
  • If you use Varnish and want to cache quickview responses for anonymous users while respecting product updates, ensure responses include correct cache tags and surrogate keys (for example, with Magento's FPC integration). Often it's easier to keep AJAX responses short-lived or uncached if they contain dynamic pricing or customer-dependent data.
  • Use ProductRepositoryInterface to fetch product data rather than raw SQL — it respects indexers. If you read attributes directly from EAV tables without using indexers, you might see inconsistent data until reindex.

Example: adding cache tags in your block to tie quick view output to product cache invalidation:

// app/code/MageFine/QuickView/Block/QuickView.php (additions)
public function getCacheKeyInfo()
{
    $product = $this->getProduct();
    return [
        'MAGEFINE_QUICKVIEW',
        $product ? 'PRODUCT_' . $product->getId() : 'NO_PRODUCT',
        // include store and currency if needed
        $this->_storeManager->getStore()->getId(),
    ];
}

public function getCacheTags()
{
    $tags = parent::getCacheTags();
    $product = $this->getProduct();
    if ($product) {
        $tags[] = 'catalog_product_' . $product->getId();
    }
    return $tags;
}

Important: If you want to support customer-specific prices or personalized content inside Quick View, consider using Magento's customer-data sections (private content) and making the AJAX endpoint return only cacheable HTML and fill private parts via JS using sections. Another pattern is to fetch product skeleton via cached HTML and then patch dynamic parts via a small JSON endpoint.

Indexing concerns

Magento indexers like catalog_product_price, catalog_product_attribute, inventory, etc., control what values are served fast. If you rely on product attributes that are indexed (prices, stock in MSI), load them using repository or collection APIs so the module reads the indexed values. If you add new product attributes that you render in Quick View, ensure they are set to be used in product listing or that reindex runs after changes.

Advanced CSS / JS customization for a smooth UX

Here are practical tips for a fluid, polished experience:

  • Keep CSS isolated: prefix your classes (magefine-qv-*) or scope rules inside the modal container. This prevents theme conflicts.
  • Render only essential markup in the AJAX response. Lazy-load heavy assets like gallery images after modal opens (use a small placeholder initially).
  • Animate smartly: use transform and opacity transitions to keep animations GPU-friendly. Avoid animating width/height where possible.
  • Debounce interactions: if the modal triggers additional API calls (configurable options), debounce or cancel previous requests for better responsiveness.
  • Use CSS variables for themeable values (colors, spacing) so themes can override only the variables.
/* app/code/MageFine/QuickView/view/frontend/web/css/quickview.css */
.magefine-qv-overlay{position:fixed;inset:0;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;z-index:10000;}
.magefine-qv-modal{background:#fff;border-radius:6px;max-width:980px;width:95%;max-height:90vh;overflow:auto;transform:translateY(-10px);opacity:0;transition:opacity .22s ease,transform .22s ease;}
.magefine-qv-overlay[aria-hidden='false'] .magefine-qv-modal{transform:translateY(0);opacity:1;}
.magefine-qv-modal img{max-width:100%;height:auto;display:block;}

Example lazy-load pattern for images inside quickview.phtml:

<img src="/static/frontend/MageFine/theme/en_US/images/placeholder.png" data-src="<?php echo $block->escapeUrl($product->getImageUrl()); ?>" class="qv-lazy" alt="..." />

// JS: after modal shown, swap data-src to src
$modal.find('img.qv-lazy').each(function(){
    var $img = $(this);
    $img.attr('src', $img.data('src')).removeAttr('data-src');
});

Accessibility and responsive design

Quick View modals must be accessible and mobile-friendly. A few rules I always follow:

  • Use role="dialog" and aria-modal="true" on the dialog container.
  • Give the dialog an accessible label: aria-label or aria-labelledby pointing to the product name heading.
  • Implement focus trap. Ensure focus is restored to the triggering element when the modal closes.
  • Keyboard controls: Escape to close, Tab/Shift+Tab to navigate inside. Buttons and links must be keyboard reachable.
  • Screen reader users: hide background content using aria-hidden on the rest of the page when the modal is open (you can add aria-hidden="true" to main content nodes while modal open).
  • Mobile: the modal should be full-screen or bottom-sheet on small viewports. Use media queries to change layout for narrow widths.

Responsive example (CSS):

@media (max-width: 640px){
    .magefine-qv-modal{width:100%;height:100%;border-radius:0;max-height:100vh;}
    .magefine-qv-modal .product-image{display:block;}
}

Performance considerations

Small things that add up:

  • Serve quickview JS/CSS via static content deploy and leverage browser caching (proper max-age headers in CDN or webserver).
  • Minify and bundle JS/CSS with your deployment pipeline.
  • If using Varnish, avoid bypassing cache for category pages. Keep quick view separate via AJAX endpoints.
  • Consider caching quickview HTML for anonymous users with short TTL and product cache tags — so product updates clear it automatically.
  • Profile the AJAX endpoint with xdebug or Blackfire when you notice slowness; the server-side render should be fast because it's a small layout (layout=empty) and only a single block.

Testing and debugging

Testing is often overlooked. Here’s a set of techniques to ensure stability in production:

Local development

  • Developer mode: bin/magento deploy:mode:set developer (so errors are visible and static files are served dynamically).
  • Enable template hints in dev to ensure your templates are loaded where you expect.

Logging & monitoring

  • Log controller exceptions to var/log/magefine_quickview.log and check var/report for production errors.
  • Monitor New Relic or similar APM for slow AJAX responses. Track both latency and error rates.

Unit and integration tests

  • PHPUnit: write unit tests for any business logic you add inside helpers or services.
  • Integration tests (Magento framework): test your controller loads and returns content for a product fixture.
  • JS tests: use Jasmine or Jest for critical logic in your quickview.js (focus trap, opening/closing behavior).

End-to-end (E2E) tests

  • Use Cypress or Selenium to assert the user flow: open category, click Quick View, modal opens, keyboard navigation works, Add to Cart adds a product, modal closes and cart updated.
  • Test on both desktop and mobile viewport sizes.

Debugging tips

  • Browser devtools network tab to inspect AJAX responses and cache headers.
  • Check Varnish hits/misses when testing anonymous users; ensure category page remains a cache hit.
  • When product data seems stale, check indexer status: bin/magento indexer:status and reindex if needed.

Advanced patterns and extensibility

If you want to extend the Quick View later, plan for these hooks:

  • Events: dispatch an event after product HTML is rendered so other modules can append content (e.g., upsell block).
  • Plugin points: allow plugins on your controller service layer to modify product data before rendering.
  • JS events: trigger custom events on modal open and close (document.dispatchEvent) so third-party scripts can react without modifying module code.
// Example: dispatch event in controller after rendering
$this->_eventManager->dispatch('magefine_quickview_after_render', ['product' => $product, 'html' => &$html]);

// JS: trigger event when modal opens
var event = new CustomEvent('magefineQuickviewOpen', {detail: {productId: id}});
document.dispatchEvent(event);

Practical checklist before deploying to production

  1. Run full test suite (unit, integration, E2E).
  2. Verify category pages remain FPC hits after module enabled (test with Varnish).
  3. Validate quick view AJAX response times under load.
  4. Ensure accessibility: keyboard navigation, screen reader labels, focus restore.
  5. Confirm mobile layout and touch interactions.
  6. Bundle and minify assets and run static content deploy in production mode.
  7. Monitor logs and APM for the first hours after launch.

Common pitfalls and how to avoid them

  • Breaking FPC by rendering quick view server-side inside category page: avoid this, use AJAX.
  • Loading heavy gallery components synchronously: lazy-load images and heavy JS only when needed.
  • Not restoring focus on modal close: remember to save the element that opened the modal and restore focus.
  • Relying on unindexed attribute values: always use repository/collection APIs that respect indexers.
  • Not including product cache tags if you cache quick view HTML: tie cache to product tags to allow automatic invalidation.

Wrap up

Building a custom Product Quick View for Magento 2 is straightforward if you separate concerns: keep the category page cacheable, fetch product content via AJAX, use repository APIs to respect indexing, and pay attention to accessibility and responsiveness. Start with the skeleton in this post, and iterate: add lazy-loading, cache tags, and tests. That approach will keep your store fast and reliable while improving conversion with a convenient Quick View UX.

If you want, I can generate a ready-to-install sample module archive or show how to adapt the Quick View to configurable products, MSI stock, or price rules. Tell me which product types you need and I’ll extend the examples.