This documentation is also published as Markdown for efficient machine reading: the whole site is indexed at /llms.txt, and every page has a clean Markdown copy under /_llms/. These are generated from the same source and cost far fewer tokens to read than this rendered HTML.

Skip to main content Skip to navigation
Guides

Ship a custom client-side widget

Add browser behavior to a static Pennington site by composing a server-rendered Mdazor component, your own script, and the head seam that loads a CDN library — built here as an image-gallery lightbox.

Pennington renders every page on the server in a single pass — there is no client-side hydration. To add interactive browser behavior (a lightbox, a chart, a copy-to-clipboard button), you ship your own script and attach it to the server-rendered HTML.

This guide builds an image-gallery lightbox from three parts: a server-rendered component that emits the markup, a browser script that enhances it, and the head content option that loads both your script and the third-party library. The worked library is GLightbox (MIT-licensed, dependency-free), but the pattern is the same for any library that scans the DOM and upgrades matching elements — the bundled Mermaid support (Embed a Mermaid diagram in a markdown page) follows it too.

Before you begin

  • A DocSite (AddDocSite) or BlogSite host — this example is a DocSite. On a bare AddPennington host the only difference is the head content: inject the tags through your own layout's <head> or a response processor that inserts before </body> (see Transform the response body on every page).
  • Familiarity with the library you are wrapping. This page covers the wiring, not GLightbox itself.
  • For a complete, running setup, see examples/BeyondClientWidgetExample; the sections below embed each of its files where they apply.

Render the markup on the server

Write an Mdazor component that emits the HTML the script will later find and upgrade. The component is plain server-side Razor — it renders thumbnails wrapped in <a class="glightbox"> anchors and nothing more. The lightbox behavior is added entirely by the script in the next step.

razor
@* ImageGallery — a server-rendered Mdazor component that emits a thumbnail grid
   of <a class="glightbox"> / <img> pairs. It runs entirely on the server; the
   client-side script (wwwroot/gallery.js) finds the `.glightbox` anchors at page
   load and upgrades them into a lightbox. This is the "SSR component plus your
   own script" seam the how-to /how-to/rich-content/client-side-widget walks
   through.

   Consumed from markdown as:

     <ImageGallery Images="merry-mixer.png, peppermint-express.png" Group="trains" />

   Only primitive parameters bind from markdown attributes, so the image list
   arrives as one comma-separated string and captions are derived from the file
   names. *@
<div class="not-prose my-8 grid grid-cols-2 gap-4 sm:grid-cols-3">
    @foreach (var image in ParsedImages)
    {
        @* target="_blank" opts the link out of Pennington's SPA navigation (it
           skips links marked target/download/data-spa-reload) so the engine does
           not hijack the click; GLightbox calls preventDefault, so the new tab
           only opens as a graceful fallback when scripting is off. *@
        <a href="@($"{BasePath}/{image.File}")"
           target="_blank" rel="noopener"
           class="glightbox group block overflow-hidden rounded-xl border border-base-200 dark:border-base-800"
           data-gallery="@Group"
           data-title="@image.Caption">
            <img src="@($"{BasePath}/{image.File}")"
                 alt="@image.Caption"
                 loading="lazy"
                 class="aspect-video w-full object-cover transition-transform duration-200 group-hover:scale-105" />
        </a>
    }
</div>
  
@code {
    /// <summary>Comma-separated image file names served under <see cref="BasePath"/>.</summary>
    [Parameter] public string Images { get; set; } = "";
  
    /// <summary>GLightbox gallery group — anchors sharing a group page through one lightbox.</summary>
    [Parameter] public string Group { get; set; } = "gallery";
  
    /// <summary>URL prefix the image files are served from.</summary>
    [Parameter] public string BasePath { get; set; } = "/guides/assets";
  
    private IEnumerable<(string File, string Caption)> ParsedImages =>
        Images.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
              .Select(file => (file, Caption: ToCaption(file)));
  
    // "merry-mixer.png" -> "Merry Mixer"
    private static string ToCaption(string file)
    {
        var name = Path.GetFileNameWithoutExtension(file).Replace('-', ' ');
        return System.Globalization.CultureInfo.InvariantCulture.TextInfo.ToTitleCase(name);
    }
}

Register the component so it is usable as a tag in markdown. AddMdazorComponent<T>() is the only DI line needed; the registry resolves the tag at render time.

csharp
using BeyondClientWidgetExample;
using BeyondClientWidgetExample.Components;
using Mdazor;
using Pennington.DocSite;
  
var builder = WebApplication.CreateBuilder(args);
  
// A DocSite whose only customization is one client-side widget: an image-gallery
// lightbox. GalleryWidget.BuildDocSiteOptions injects the GLightbox CDN assets
// and the local init script into <head>; AddMdazorComponent<ImageGallery>()
// registers the server-rendered tag the script enhances. Backs the how-to
// /how-to/rich-content/client-side-widget.
builder.Services.AddDocSite(GalleryWidget.BuildDocSiteOptions);
builder.Services.AddMdazorComponent<ImageGallery>();
  
var app = builder.Build();
  
app.UseDocSite();
  
await app.RunDocSiteAsync(args);

Only primitive attributes bind from markdown, so the image list arrives as one comma-separated string and the component derives a caption from each file name. For the binding rules and the structured-data workarounds, see Drop a Razor component into a markdown page.

Write the browser script

The script runs in the browser, finds the server-rendered anchors, and hands them to the library. Keep the initializer idempotent — it runs once on the first load and again after every in-site navigation (covered under Survive SPA navigation below).

javascript
// gallery.js — the client half of the image-gallery widget.
//
// The server renders <a class="glightbox"> thumbnails (Components/ImageGallery.razor);
// this script finds them in the browser and upgrades them into a lightbox.
// GLightbox itself loads from a CDN <script> in <head> (see GalleryWidget.cs), so
// the global GLightbox function is available by the time this deferred script runs.
let lightbox = null;
  
function initGallery() {
    if (typeof GLightbox !== 'function') return;
    // When re-running after an in-site navigation, tear down the previous
    // instance first so its event listeners don't accumulate.
    lightbox?.destroy();
    lightbox = GLightbox({ selector: '.glightbox' });
}
  
// First full page load. A deferred script runs after the DOM is parsed, so the
// gallery markup is already present.
if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', initGallery);
} else {
    initGallery();
}
  
// Pennington swaps page content on in-site navigation without a full reload, so
// re-scan for galleries after each SPA commit. No-op if spa-engine.js is absent.
document.addEventListener('spa:commit', initGallery);

Put the script in wwwroot, where the host serves it at /gallery.js and the static build copies it to the output.

Load the library and your script

DocSiteOptions.AdditionalHtmlHeadContent is a raw HTML string rendered inside every page's <head> — the place for the library's stylesheet and script plus your own. Load the library first, then your script. Both <script> tags use defer, so they execute in document order: the library defines its global before your script calls it.

csharp
=> """
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/glightbox.min.css">
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/glightbox.min.js" defer></script>
<script src="/gallery.js" defer></script>
"""

Pin the library to a version so the build is reproducible, and assign the fragment to the head content option on the options record:

csharp
public static DocSiteOptions BuildDocSiteOptions() => new()
{
    SiteTitle = "Client Widget Example",
    SiteDescription = "Ships an image-gallery lightbox by composing a CDN script, the head seam, and a server-rendered Mdazor component.",
    AdditionalHtmlHeadContent = BuildGalleryHeadContent(),
    Areas =
    [
        new ContentArea("Guides", "guides"),
    ],
};

For a site that builds offline or behind a firewall, vendor the two library files into wwwroot and point the tags at the local copies — a CDN load fails silently otherwise.

Display the media the widget references

Colocate the images the gallery displays under Content, next to the page that uses them — here, Content/guides/assets/. Pennington copies colocated assets to the output and the build's link checker recognizes them, so referencing them from the rendered markup keeps the build clean. See Add images and shared assets to a page.

Use it in a page

Drop the tag into any markdown page. The Images attribute is the comma-separated file list, Group ties the thumbnails into one lightbox so the arrow keys move between them, and BasePath (optional) is the URL prefix the files are served from.

markdown
<ImageGallery Images="peppermint-express.png, merry-mixer.png, indigo-inchworm.png" Group="trains" />

Survive SPA navigation

Pennington's SPA engine swaps page content on in-site navigation without a full reload, which affects a client widget two ways.

Re-run your initializer. DOMContentLoaded fires only on the first full load. After an in-site navigation the new page's markup arrives through a region swap, so re-bind from the spa:commit event — gallery.js above adds one listener for exactly this. See the SPA lifecycle events.

Opt link-triggered widgets out of navigation. The engine treats same-origin <a> clicks as navigation. Because the gallery thumbnails are links to the full image, an un-marked click would be intercepted by the engine instead of opening the lightbox. The engine automatically skips links marked target="_blank" or download (see anchor attributes), so the component sets target="_blank" — GLightbox calls preventDefault, making the new tab a graceful fallback only when scripting is off.

Verify

  • Run dotnet run --project examples/BeyondClientWidgetExample and open /guides/image-gallery. Click a thumbnail — the lightbox opens. Navigate to the page through an in-site link and click again — it still opens, confirming the spa:commit re-init.
  • Run dotnet run --project examples/BeyondClientWidgetExample -- build output. Confirm output/gallery.js, the images under output/guides/assets/, and the <a class="glightbox"> markup in output/guides/image-gallery/index.html are all present, and that the build reports no broken links.