Morpheus Release Notes
Released June 2026.
This page features interactive demos and side-by-side code samples that need at least 340 pixels of horizontal space. Please widen your browser window, or rotate your device, to continue.
Component <neo-lightbox> is an overlay that expands arbitrary content from one or more triggers. Mark each opener with [data-neo-lightbox-trigger] and the body with [data-neo-lightbox-content]; the host owns the open / close animation, focus, the backdrop, and contained confines the overlay to the host's own box.
The preview routes through the in-browser server simulator. Its current Settings (network latency, a non-200 response, or an unreachable server) are in effect, so the live preview can look like it is misbehaving. Adjust them in the Settings panel.
The selected state's markup and its scoped CSS. The CSS is wrapped in <style>@scope { ... }</style> and applies to the preview only. Edits change the preview above, applied live while Sync is on, or when you click Patch or Replace. Patch morphs the preview; Replace swaps it outright.
Datastar signals declared in the selected state's markup. Change a value to drive the preview, applied live while Sync is on, or when you click Patch.
| Name | Type | Default | Description |
|---|---|---|---|
open | boolean | false | Open state. |
contained | boolean | false | Where the overlay renders. Default promotes it to the top layer covering the viewport; contained confines it to the host's own box, which becomes the positioned containing block. |
hover | boolean | false | Open on trigger hover (mouse only). Click still opens, instantly, for touch and keyboard. |
hover-open-delay | integer (ms) | 100 | Delay before a hover opens the overlay. |
hover-close-delay | integer (ms) | 200 | Delay before a hover closes it after the pointer leaves both trigger and surface. |
dismissible | boolean | true | Set false to disable backdrop-press and |
transition | "zoom" | "fade" | "none" | "zoom" | Entry / exit style. zoom morphs the surface from the opening trigger's position and size (a FLIP) and back into it on close; fade is a centered scale + opacity; none is instant. The backdrop fades in every mode. Honors reduced motion. Read when the overlay opens, so a change while it is open applies on the next open. |
| Name | Description |
|---|---|
[data-neo-lightbox-trigger] | Opens the overlay. Repeat on any number of children; all open the same surface. |
[data-neo-lightbox-content] | Arbitrary HTML projected into the overlay surface. |
[data-neo-lightbox-close] | Any descendant of the content; clicking it dismisses the overlay. |
[data-neo-lightbox-title] | Names the dialog; the surface's aria-labelledby points at it. |
| Name | Detail | Bubbles | Description |
|---|---|---|---|
neo-lightbox-open | { trigger } | yes | Fires when the overlay opens. trigger is the activating element. |
neo-lightbox-close | none | yes | Fires when the overlay closes. |
| Name | Description |
|---|---|
::part(backdrop) | The dimmed layer behind the surface. |
::part(surface) | The centered dialog panel. |
| Name | Default | Description |
|---|---|---|
--neo-lightbox-overlay-bg | rgba(0, 0, 0, 0.72) | Backdrop color. |
--neo-lightbox-overlay-blur | 0px | Backdrop blur radius. |
--neo-lightbox-surface-bg | transparent | Surface background. |
--neo-lightbox-color | --page-fg | Surface text color. |
--neo-lightbox-radius | 0 | Surface corner radius. |
--neo-lightbox-surface-padding | 0 | Surface padding. |
--neo-lightbox-shadow | none | Surface shadow. |
--neo-lightbox-max-width | 90vw | Surface width cap. |
--neo-lightbox-max-height | 90vh | Surface height cap. |
--neo-lightbox-screen-offset | 1rem | Gap kept around the overlay. |
--neo-lightbox-enter-duration | 200ms | Open / close animation duration. |
--neo-lightbox-enter-scale | 0.94 | Start scale for the fade transition. |
--neo-lightbox-z-index | 50 | Overlay stacking when contained. |
A thumbnail opens its full-size image on the top layer over a dimmed backdrop. By default the image zooms out of the thumbnail and shrinks back into it on close (the transition="zoom" FLIP). Press the backdrop, the close button, or
<neo-lightbox>
<button data-neo-lightbox-trigger class="lightbox-default-trigger">
<img src="/static/cheshire_cat.webp" alt="The Cheshire cat" width="220">
</button>
<div data-neo-lightbox-content>
<img src="/static/cheshire_cat.webp" alt="The Cheshire cat">
<button data-neo-lightbox-close aria-label="Close">×</button>
</div>
</neo-lightbox>package examples
import "github.com/romshark/morpheus/neo"
templ LightboxDefault() {
@neo.Lightbox(neo.LightboxOpts{}) {
<button { neo.LightboxTrigger()... } class="lightbox-default-trigger">
<img src="/static/cheshire_cat.webp" alt="The Cheshire cat" width="220"/>
</button>
<div { neo.LightboxContent()... }>
<img src="/static/cheshire_cat.webp" alt="The Cheshire cat"/>
<button { neo.LightboxClose()... } aria-label="Close">×</button>
</div>
}
}
.lightbox-default-trigger {
cursor: zoom-in;
}
The content slot takes any HTML, not just images. This one is a panel with a heading, a description list, and its own close button. A [data-neo-lightbox-title] gives the dialog its accessible name.
<neo-lightbox transition="fade" aria-label="Keyboard shortcuts">
<neo-button role="button" tabindex="0" data-neo-lightbox-trigger>Keyboard shortcuts</neo-button>
<div class="lightbox-content-panel" data-neo-lightbox-content>
<header class="lightbox-content-header">
<h2 data-neo-lightbox-title class="lightbox-content-title">Keyboard shortcuts</h2>
<button
data-neo-lightbox-close
aria-label="Close"
class="lightbox-content-close"
>×</button>
</header>
<dl class="lightbox-content-list">
<dt><neo-kbd>/</neo-kbd></dt>
<dd class="lightbox-content-dd">Focus search</dd>
<dt><neo-kbd>g</neo-kbd><neo-kbd>h</neo-kbd></dt>
<dd class="lightbox-content-dd">Go home</dd>
<dt><neo-kbd key="esc"></neo-kbd></dt>
<dd class="lightbox-content-dd">Close this dialog</dd>
</dl>
</div>
</neo-lightbox>package examples
import "github.com/romshark/morpheus/neo"
templ LightboxContent() {
@neo.LightboxAttrs(neo.LightboxOpts{Transition: neo.Set(neo.LightboxTransitionFade)}, templ.Attributes{
"aria-label": "Keyboard shortcuts",
}) {
@neo.ButtonAttrs(neo.ButtonOpts{}, neo.LightboxTrigger()) {
Keyboard shortcuts
}
<div
class="lightbox-content-panel"
{ neo.LightboxContent()... }
>
<header class="lightbox-content-header">
<h2 { neo.LightboxTitle()... } class="lightbox-content-title">Keyboard shortcuts</h2>
<button
{ neo.LightboxClose()... }
aria-label="Close"
class="lightbox-content-close"
>×</button>
</header>
<dl class="lightbox-content-list">
<dt>
@neo.Kbd(neo.KbdOpts{}) {
/
}
</dt>
<dd class="lightbox-content-dd">Focus search</dd>
<dt>
@neo.Kbd(neo.KbdOpts{}) {
g
}
{ " " }
@neo.Kbd(neo.KbdOpts{}) {
h
}
</dt>
<dd class="lightbox-content-dd">Go home</dd>
<dt>
@neo.Kbd(neo.KbdOpts{Key: neo.Set("esc")})
</dt>
<dd class="lightbox-content-dd">Close this dialog</dd>
</dl>
</div>
}
}
.lightbox-content-panel {
box-sizing: border-box;
width: min(90vw, 28rem);
background: var(--page-bg, #fff);
color: var(--page-fg, #111);
border-radius: 0.75rem;
padding: 1.5rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.35);
}
.lightbox-content-header {
display: flex;
align-items: start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.lightbox-content-title {
margin: 0;
font-size: 1.125rem;
}
.lightbox-content-close {
border: 0;
background: none;
font-size: 1.4rem;
line-height: 1;
cursor: pointer;
color: inherit;
}
.lightbox-content-list {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem 1rem;
margin: 0;
}
.lightbox-content-dd {
margin: 0;
}
contained confines the overlay to the host's box instead of the viewport, so the panel covers just this card. The host becomes the positioned containing block.
Released June 2026.
<neo-lightbox> overlay component.<neo-lightbox contained>
<article>
<h3>Morpheus Release Notes</h3>
<p>Released June 2026.</p>
<neo-button data-neo-lightbox-trigger variant="primary">View release notes</neo-button>
</article>
<div data-neo-lightbox-content>
<header>
<h3 data-neo-lightbox-title>Release notes</h3>
<button data-neo-lightbox-close aria-label="Close">×</button>
</header>
<ul>
<li>New <code><neo-lightbox></code> overlay component.</li>
<li>Carousel deep-linking from gallery thumbnails.</li>
<li>Reduced-motion and forced-colors passes across the kit.</li>
</ul>
</div>
</neo-lightbox>package examples
import "github.com/romshark/morpheus/neo"
templ LightboxContainer() {
@neo.Lightbox(neo.LightboxOpts{Contained: neo.Set(true)}) {
<article>
<h3>Morpheus Release Notes</h3>
<p>Released June 2026.</p>
<neo-button { neo.LightboxTrigger()... } variant="primary">
View release notes
</neo-button>
</article>
<div { neo.LightboxContent()... }>
<header>
<h3 { neo.LightboxTitle()... }>Release notes</h3>
<button { neo.LightboxClose()... } aria-label="Close">×</button>
</header>
<ul>
<li>New <code>{ "<neo-lightbox>" }</code> overlay component.</li>
<li>Carousel deep-linking from gallery thumbnails.</li>
<li>Reduced-motion and forced-colors passes across the kit.</li>
</ul>
</div>
}
}
With hover the overlay opens on pointer enter and closes after the pointer leaves both trigger and surface. Click still opens it, instantly, for touch and keyboard. Hover the button to see it.

<neo-lightbox
contained
hover
hover-open-delay="120"
hover-close-delay="200"
transition="fade"
aria-label="Photo preview"
class="lb-hover-preview"
>
<div class="lb-hover-frame">
<neo-button role="button" tabindex="0" data-neo-lightbox-trigger>Hover to preview</neo-button>
</div>
<div class="lb-hover-content" data-neo-lightbox-content>
<img src="/static/jar_5.webp" alt="A glass jar" class="lb-hover-img">
</div>
</neo-lightbox>package examples
import "github.com/romshark/morpheus/neo"
templ LightboxHover() {
@neo.LightboxAttrs(neo.LightboxOpts{
Contained: neo.Set(true),
Transition: neo.Set(neo.LightboxTransitionFade),
Hover: neo.Set(true),
HoverOpenDelay: neo.Set(120),
HoverCloseDelay: neo.Set(200),
}, templ.Attributes{
"class": "lb-hover-preview",
"aria-label": "Photo preview",
}) {
<div class="lb-hover-frame">
@neo.ButtonAttrs(neo.ButtonOpts{}, neo.LightboxTrigger()) {
Hover to preview
}
</div>
<div
class="lb-hover-content"
{ neo.LightboxContent()... }
>
<img
src="/static/jar_5.webp"
alt="A glass jar"
class="lb-hover-img"
/>
</div>
}
}
.lb-hover-preview {
width: 100%;
height: 100%;
min-height: 18rem;
--neo-lightbox-screen-offset: 0;
--neo-lightbox-overlay-bg: rgba(0, 0, 0, 0.35);
}
/* The surface centers and shrink-wraps its content by default; give it
a definite size so the image's height:100% resolves and object-fit
has a box to letterbox into. */
.lb-hover-preview::part(surface) {
width: 100%;
height: 100%;
}
.lb-hover-frame {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 100%;
height: 100%;
border: 1px dashed var(--btn-border, rgba(0, 0, 0, 0.2));
border-radius: 0.5rem;
}
.lb-hover-content {
width: 100%;
height: 100%;
line-height: 0;
}
.lb-hover-img {
width: 100%;
height: 100%;
border-radius: 0.5rem;
object-fit: contain;
}
Every thumbnail opens the same overlay, which holds a <neo-carousel> of the full images. A $gallery_active signal tracks the last thumbnail clicked: each trigger sets it (data-on:click), and the carousel binds to it (data-attr:value) and writes back on navigation, so the photo you click opens first and the signal stays in sync as you swipe.






<div data-signals="{gallery_active: '1'}">
<neo-lightbox transition="fade" aria-label="Jar gallery">
<ul class="lightbox-gallery-grid">
<li>
<button
data-neo-lightbox-trigger
data-on:click="$gallery_active = '1'"
class="lightbox-gallery-thumb"
>
<img
src="/static/jar_1.webp"
alt="Jar 1"
class="lightbox-gallery-thumb-img"
>
</button>
</li>
<li>
<button
data-neo-lightbox-trigger
data-on:click="$gallery_active = '2'"
class="lightbox-gallery-thumb"
>
<img
src="/static/jar_2.webp"
alt="Jar 2"
class="lightbox-gallery-thumb-img"
>
</button>
</li>
<li>
<button
data-neo-lightbox-trigger
data-on:click="$gallery_active = '3'"
class="lightbox-gallery-thumb"
>
<img
src="/static/jar_3.webp"
alt="Jar 3"
class="lightbox-gallery-thumb-img"
>
</button>
</li>
<li>
<button
data-neo-lightbox-trigger
data-on:click="$gallery_active = '4'"
class="lightbox-gallery-thumb"
>
<img
src="/static/jar_4.webp"
alt="Jar 4"
class="lightbox-gallery-thumb-img"
>
</button>
</li>
<li>
<button
data-neo-lightbox-trigger
data-on:click="$gallery_active = '5'"
class="lightbox-gallery-thumb"
>
<img
src="/static/jar_5.webp"
alt="Jar 5"
class="lightbox-gallery-thumb-img"
>
</button>
</li>
<li>
<button
data-neo-lightbox-trigger
data-on:click="$gallery_active = '6'"
class="lightbox-gallery-thumb"
>
<img
src="/static/jar_6.webp"
alt="Jar 6"
class="lightbox-gallery-thumb-img"
>
</button>
</li>
</ul>
<div data-neo-lightbox-content class="lightbox-gallery-content">
<neo-carousel
loop
dots
class="lightbox-gallery-carousel"
data-attr:value="$gallery_active"
data-on:neo-carousel-change="$gallery_active = evt.detail.value"
>
<neo-carousel-track>
<neo-carousel-slide value="1" class="lightbox-gallery-slide">
<img
src="/static/jar_1.webp"
alt="Jar 1"
class="lightbox-gallery-slide-img"
>
</neo-carousel-slide>
<neo-carousel-slide value="2" class="lightbox-gallery-slide">
<img
src="/static/jar_2.webp"
alt="Jar 2"
class="lightbox-gallery-slide-img"
>
</neo-carousel-slide>
<neo-carousel-slide value="3" class="lightbox-gallery-slide">
<img
src="/static/jar_3.webp"
alt="Jar 3"
class="lightbox-gallery-slide-img"
>
</neo-carousel-slide>
<neo-carousel-slide value="4" class="lightbox-gallery-slide">
<img
src="/static/jar_4.webp"
alt="Jar 4"
class="lightbox-gallery-slide-img"
>
</neo-carousel-slide>
<neo-carousel-slide value="5" class="lightbox-gallery-slide">
<img
src="/static/jar_5.webp"
alt="Jar 5"
class="lightbox-gallery-slide-img"
>
</neo-carousel-slide>
<neo-carousel-slide value="6" class="lightbox-gallery-slide">
<img
src="/static/jar_6.webp"
alt="Jar 6"
class="lightbox-gallery-slide-img"
>
</neo-carousel-slide>
</neo-carousel-track>
<button
aria-label="Previous image"
class="lightbox-gallery-nav lightbox-gallery-nav-prev"
data-neo-carousel-prev
>‹</button>
<button
aria-label="Next image"
class="lightbox-gallery-nav lightbox-gallery-nav-next"
data-neo-carousel-next
>›</button>
</neo-carousel>
<button
data-neo-lightbox-close
aria-label="Close gallery"
class="lightbox-gallery-close"
>×</button>
</div>
</neo-lightbox>
</div>package examples
import "github.com/romshark/morpheus/neo"
templ LightboxGallery() {
<div data-signals="{gallery_active: '1'}">
@neo.LightboxAttrs(neo.LightboxOpts{Transition: neo.Set(neo.LightboxTransitionFade)}, templ.Attributes{
"aria-label": "Jar gallery",
}) {
<ul class="lightbox-gallery-grid">
for _, n := range []string{"1", "2", "3", "4", "5", "6"} {
<li>
<button
{ neo.LightboxTrigger()... }
data-on:click={ "$gallery_active = '" + n + "'" }
class="lightbox-gallery-thumb"
>
<img src={ "/static/jar_" + n + ".webp" } alt={ "Jar " + n } class="lightbox-gallery-thumb-img"/>
</button>
</li>
}
</ul>
<div { neo.LightboxContent()... } class="lightbox-gallery-content">
@neo.CarouselAttrs(neo.CarouselOpts{Loop: neo.Set(true), Dots: neo.Set(true)}, templ.Attributes{
"data-attr:value": "$gallery_active",
"data-on:neo-carousel-change": "$gallery_active = evt.detail.value",
"class": "lightbox-gallery-carousel",
}) {
@neo.CarouselTrack() {
for _, n := range []string{"1", "2", "3", "4", "5", "6"} {
@neo.CarouselSlideAttrs(n, templ.Attributes{
"class": "lightbox-gallery-slide",
}) {
<img src={ "/static/jar_" + n + ".webp" } alt={ "Jar " + n } class="lightbox-gallery-slide-img"/>
}
}
}
<button aria-label="Previous image" class="lightbox-gallery-nav lightbox-gallery-nav-prev" { neo.CarouselPrev()... }>‹</button>
<button aria-label="Next image" class="lightbox-gallery-nav lightbox-gallery-nav-next" { neo.CarouselNext()... }>›</button>
}
<button
{ neo.LightboxClose()... }
aria-label="Close gallery"
class="lightbox-gallery-close"
>×</button>
</div>
}
</div>
}
.lightbox-gallery-grid {
list-style: none;
margin: 0;
padding: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
gap: 0.5rem;
}
.lightbox-gallery-thumb {
display: block;
width: 100%;
padding: 0;
border: 0;
background: none;
border-radius: 0.4rem;
overflow: hidden;
line-height: 0;
cursor: zoom-in;
}
.lightbox-gallery-thumb-img {
display: block;
width: 100%;
aspect-ratio: 1;
object-fit: cover;
}
.lightbox-gallery-content {
position: relative;
width: min(92vw, 52rem);
}
.lightbox-gallery-carousel {
display: block;
position: relative;
width: 100%;
height: min(80vh, 32rem);
background: #000;
border-radius: 0.5rem;
overflow: hidden;
}
.lightbox-gallery-slide {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.lightbox-gallery-slide-img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.lightbox-gallery-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 2.5rem;
height: 2.5rem;
border: 0;
border-radius: 999px;
background: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 1.4rem;
line-height: 1;
cursor: pointer;
}
.lightbox-gallery-nav-prev {
left: 0.6rem;
}
.lightbox-gallery-nav-next {
right: 0.6rem;
}
.lightbox-gallery-close {
position: absolute;
top: 0.6rem;
right: 0.6rem;
width: 2.25rem;
height: 2.25rem;
border: 0;
border-radius: 999px;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 1.2rem;
line-height: 1;
cursor: pointer;
z-index: 1;
}