---
title: Ship a custom client-side widget
description: "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."
canonical_url: https://usepennington.net/how-to/rich-content/client-side-widget/
sidecar_url: https://usepennington.net/how-to/rich-content/client-side-widget.md
content_hash: sha256:756480aed5207cbfb9baed646c65bad209dc774f49858691e706e5e996f592ac
tokens: 3205
uid: how-to.rich-content.client-side-widget
reading_time_minutes: 5
---

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](https://github.com/biati-digital/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](https://usepennington.net/how-to/rich-content/diagrams.md)) 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](https://usepennington.net/how-to/response-pipeline/response-processor.md)).
 - 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:symbol
@* 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:symbol
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](https://usepennington.net/how-to/rich-content/ui-components-in-markdown.md).

 
## 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:symbol
// 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:symbol,bodyonly
=> """
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/glightbox@3.3.1/dist/css/glightbox.min.css">
<script src="https://cdn.jsdelivr.net/npm/glightbox@3.3.1/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:symbol
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](https://usepennington.net/how-to/pages/images-and-assets.md).

 
## 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](https://usepennington.net/reference/spa/attributes.md#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](https://usepennington.net/reference/spa/attributes.md#anchor-and-stylesheet-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.
 
 
## Related

 
 - How-to: [Drop a Razor component into a markdown page](https://usepennington.net/how-to/rich-content/ui-components-in-markdown.md) — the attribute-binding rules for the Mdazor tag this widget renders.
 - How-to: [Transform the response body on every page](https://usepennington.net/how-to/response-pipeline/response-processor.md) — inject the head/`</body>` tags on a bare `AddPennington` host instead of through `DocSiteOptions`.
 - How-to: [Add images and shared assets to a page](https://usepennington.net/how-to/pages/images-and-assets.md) — where page-referenced images and shared assets live.
 - Reference: [SPA engine attributes and events](https://usepennington.net/reference/spa/attributes.md) — the SPA engine's anchor opt-outs and `spa:commit` lifecycle event.
 - Background: [SPA navigation through region swaps](https://usepennington.net/explanation/spa/islands.md) — why the SPA model is server-rendered with no client hydration.
 
 
[Previous
                
                Tab platform or language variants together](https://usepennington.net/how-to/rich-content/content-tabs.md)[Next
                    
                Reorder, rename, or hide entries in the sidebar](https://usepennington.net/how-to/navigation/customize-sidebar.md)