Model: gemini/gemini-3-flash-preview (resolved: gemini-3-flash-preview)
Build a Web Component that implements a simple image gallery. Usage is like this:
<image-gallery width="5">
<img src="image1.jpg" alt="Image 1">
<img src="image2.jpg" alt="Image 2" data-thumb="image2-thumb.jpg">
<img src="image3.jpg" alt="Image 3">
</image-gallery>
If an image has a data-thumb= attribute that one is used instead, other images are scaled down.
The image gallery always takes up 100% of available width. The width="5" attribute means that five images will be shown next to each other in each row. The default is 3. There are gaps between the images. When an image is clicked it opens a modal dialog with the full size image.
Return a complete HTML file with both the implementation of the Web Component several example uses of it. Use https://picsum.photos/300/200 URLs for those example images.
This is a complete, single-file solution. It uses a Web Component (<image-gallery>) with a Shadow DOM for style encapsulation, CSS Grid for the layout, and Slots to manage the user-provided images.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Web Component Image Gallery</title>
<style>
body {
font-family: sans-serif;
line-height: 1.6;
padding: 20px;
background-color: #f4f4f4;
}
h2 { margin-top: 40px; }
code { background: #eee; padding: 2px 4px; }
</style>
</head>
<body>
<h1>Web Component Image Gallery</h1>
<h2>Gallery 1: Default (3 columns)</h2>
<image-gallery>
<img src="https://picsum.photos/1200/800?random=1" alt="Nature">
<img src="https://picsum.photos/1200/800?random=2" alt="Architecture" data-thumb="https://picsum.photos/300/200?random=2">
<img src="https://picsum.photos/1200/800?random=3" alt="Tech">
</image-gallery>
<h2>Gallery 2: Custom Width (5 columns)</h2>
<image-gallery width="5">
<img src="https://picsum.photos/1200/800?random=11" alt="Random 1">
<img src="https://picsum.photos/1200/800?random=12" alt="Random 2">
<img src="https://picsum.photos/1200/800?random=13" alt="Random 3" data-thumb="https://picsum.photos/300/200?random=13">
<img src="https://picsum.photos/1200/800?random=14" alt="Random 4">
<img src="https://picsum.photos/1200/800?random=15" alt="Random 5">
<img src="https://picsum.photos/1200/800?random=16" alt="Random 6">
<img src="https://picsum.photos/1200/800?random=17" alt="Random 7">
</image-gallery>
<!-- Implementation starts here -->
<script>
class ImageGallery extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this._columns = this.getAttribute('width') || 3;
}
static get observedAttributes() {
return ['width'];
}
attributeChangedCallback(name, oldVal, newVal) {
if (name === 'width' && oldVal !== newVal) {
this._columns = newVal;
this.updateStyles();
}
}
connectedCallback() {
this.render();
this.setupGallery();
}
updateStyles() {
const host = this.shadowRoot.host;
host.style.setProperty('--grid-cols', this._columns);
}
setupGallery() {
const slot = this.shadowRoot.querySelector('slot');
// When images are added to the slot
slot.addEventListener('slotchange', () => {
const elements = slot.assignedElements();
elements.forEach(el => {
if (el.tagName === 'IMG') {
// Store original source for full size
const fullSize = el.src;
// If data-thumb exists, swap it for the gallery view
if (el.dataset.thumb) {
el.src = el.dataset.thumb;
}
// Add click event for modal
el.addEventListener('click', () => this.openModal(fullSize, el.alt));
// Ensure images fill their grid cell
el.style.width = '100%';
el.style.height = '100%';
el.style.objectFit = 'cover';
el.style.cursor = 'pointer';
el.style.display = 'block';
el.style.transition = 'transform 0.2s ease';
}
});
});
}
openModal(src, alt) {
const modal = this.shadowRoot.querySelector('.modal');
const modalImg = this.shadowRoot.querySelector('.modal-content');
modalImg.src = src;
modalImg.alt = alt;
modal.style.display = 'flex';
}
closeModal() {
this.shadowRoot.querySelector('.modal').style.display = 'none';
}
render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: grid;
grid-template-columns: repeat(var(--grid-cols, 3), 1fr);
gap: 15px;
width: 100%;
margin: 20px 0;
}
/* Modal Styles */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.9);
justify-content: center;
align-items: center;
cursor: zoom-out;
}
.modal-content {
max-width: 90%;
max-height: 90%;
box-shadow: 0 0 20px rgba(0,0,0,0.5);
cursor: default;
}
::slotted(img:hover) {
filter: brightness(0.8);
}
.close-btn {
position: absolute;
top: 20px;
right: 30px;
color: white;
font-size: 40px;
font-weight: bold;
cursor: pointer;
}
</style>
<slot></slot>
<div class="modal">
<span class="close-btn">×</span>
<img class="modal-content">
</div>
`;
this.updateStyles();
// Close modal events
this.shadowRoot.querySelector('.modal').addEventListener('click', () => this.closeModal());
this.shadowRoot.querySelector('.close-btn').addEventListener('click', () => this.closeModal());
}
}
// Define the new element
customElements.define('image-gallery', ImageGallery);
</script>
</body>
</html>- Dynamic Grid: Uses
display: gridand CSS variables (--grid-cols) to dynamically adjust the number of columns based on thewidthattribute. data-thumbSupport: The component checks for the presence of adata-thumbattribute on any slottedimg. If found, it swaps thesrcto the thumbnail for the gallery view but keeps the originalsrcfor the modal.- Encapsulation: All gallery styling and the modal logic are contained within the Shadow DOM, meaning it won't interfere with the rest of your website's CSS.
- Full Width: The
:hostis set towidth: 100%, and the grid items expand to fill the available space evenly. - Modal Logic:
- Clicking an image triggers
openModal. - Clicking the backdrop or the "X" button closes it.
- The modal shows the high-resolution version of the image (the original
src).
- Clicking an image triggers
- Usage flexibility: You can change the
widthattribute dynamically via JavaScript (e.g.,document.querySelector('image-gallery').setAttribute('width', '2')) and the layout will update instantly thanks toobservedAttributes.
225 input, 3,269 output, {"candidatesTokenCount": 2001, "promptTokensDetails": [{"modality": "TEXT", "tokenCount": 225}], "thoughtsTokenCount": 1268}
Use a real modal such that keyboard shortcuts and accessibility features work without extra JS
To implement a "real modal" with native accessibility features (like the Escape key to close and focus trapping) with minimal custom JavaScript, we use the HTML <dialog> element.
The <dialog> element is the browser's native way to handle modals. When opened using the .showModal() method, it automatically handles the backdrop, z-index, and keyboard shortcuts.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Accessible Web Component Gallery</title>
<style>
body {
font-family: system-ui, -apple-system, sans-serif;
line-height: 1.5;
max-width: 1000px;
margin: 0 auto;
padding: 2rem;
background-color: #f0f2f5;
}
section { margin-bottom: 3rem; }
code { background: #e0e0e0; padding: 0.2rem 0.4rem; border-radius: 4px; }
</style>
</head>
<body>
<h1>Native Image Gallery Component</h1>
<section>
<h2>Default Gallery (3 columns)</h2>
<image-gallery>
<img src="https://picsum.photos/1200/800?random=1" alt="Mountain View">
<!-- This image uses a specific thumbnail -->
<img src="https://picsum.photos/1200/800?random=2" alt="Cityscape" data-thumb="https://picsum.photos/300/200?random=2">
<img src="https://picsum.photos/1200/800?random=3" alt="Forest Path">
</image-gallery>
</section>
<section>
<h2>Custom Width (5 columns)</h2>
<p>This gallery uses <code>width="5"</code> and contains mix of thumbnails and full images.</p>
<image-gallery width="5">
<img src="https://picsum.photos/1200/800?random=4" alt="Abstract 1">
<img src="https://picsum.photos/1200/800?random=5" alt="Abstract 2">
<img src="https://picsum.photos/1200/800?random=6" alt="Abstract 3" data-thumb="https://picsum.photos/300/200?random=6">
<img src="https://picsum.photos/1200/800?random=7" alt="Abstract 4">
<img src="https://picsum.photos/1200/800?random=8" alt="Abstract 5">
<img src="https://picsum.photos/1200/800?random=9" alt="Abstract 6">
<img src="https://picsum.photos/1200/800?random=10" alt="Abstract 7">
</image-gallery>
</section>
<!-- Web Component Implementation -->
<script>
class ImageGallery extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
static get observedAttributes() {
return ['width'];
}
attributeChangedCallback() {
this.render();
}
connectedCallback() {
this.render();
}
handleImageClick(e) {
const img = e.target.closest('img');
if (!img) return;
const dialog = this.shadowRoot.querySelector('dialog');
const modalImg = dialog.querySelector('.modal-img');
// Use the stored full-size URL
modalImg.src = img.dataset.fullsize;
modalImg.alt = img.alt;
// Native API: handles focus trapping and ESC key automatically
dialog.showModal();
}
render() {
const cols = this.getAttribute('width') || 3;
this.shadowRoot.innerHTML = `
<style>
:host {
display: grid;
grid-template-columns: repeat(${cols}, 1fr);
gap: 12px;
width: 100%;
}
::slotted(img) {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
cursor: pointer;
display: block;
border-radius: 4px;
transition: opacity 0.2s;
}
::slotted(img:hover) {
opacity: 0.8;
}
/* Dialog (Modal) Styling */
dialog {
padding: 0;
border: none;
border-radius: 8px;
max-width: 90vw;
max-height: 90vh;
overflow: hidden;
background: transparent;
}
dialog::backdrop {
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(4px);
}
.modal-container {
display: flex;
flex-direction: column;
position: relative;
}
.modal-img {
max-width: 100%;
max-height: 85vh;
display: block;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
}
.close-btn {
align-self: flex-end;
background: white;
border: none;
padding: 8px 16px;
margin-bottom: 8px;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
font-family: inherit;
}
</style>
<slot id="gallery-slot"></slot>
<dialog>
<div class="modal-container">
<form method="dialog">
<button class="close-btn" aria-label="Close">Close</button>
</form>
<img class="modal-img" src="" alt="">
</div>
</dialog>
`;
this.setupImages();
// Close dialog if clicking the backdrop
const dialog = this.shadowRoot.querySelector('dialog');
dialog.addEventListener('click', (e) => {
if (e.target === dialog) dialog.close();
});
}
setupImages() {
const slot = this.shadowRoot.querySelector('#gallery-slot');
const images = slot.assignedElements();
images.forEach(img => {
if (img.tagName === 'IMG') {
// Store the high-res source for the modal
const originalSrc = img.src;
img.dataset.fullsize = originalSrc;
// If thumb exists, use it for the gallery view
if (img.dataset.thumb) {
img.src = img.dataset.thumb;
}
// Event listener to open modal
img.onclick = (e) => this.handleImageClick(e);
}
});
}
}
customElements.define('image-gallery', ImageGallery);
</script>
</body>
</html>- The
<dialog>Element: Usingdialog.showModal()triggers a built-in browser behavior that treats the element as a top-layer modal. - Keyboard Support: The Escape key closes the modal automatically without any custom
keydownevent listeners. - Focus Management: When the modal opens, the browser automatically moves focus to the first focusable element inside (the "Close" button). When it closes, focus is returned to the image that triggered it.
- Semantic Form: I used
<form method="dialog">around the close button. This is a special native feature where submitting the form (clicking the button) automatically closes the dialog without needing a JavaScriptclickhandler. - Backdrop: The
::backdropCSS pseudo-element is used for the background dimming, which is only visible when the dialog is open as a modal. - Data Handling: The component saves the original high-resolution
srcinto adata-fullsizeattribute internally so it can swap the gallery view to the thumbnail without losing the link to the large image.
2,243 input, 2,908 output, {"candidatesTokenCount": 2061, "promptTokensDetails": [{"modality": "TEXT", "tokenCount": 2243}], "thoughtsTokenCount": 847}
Use X for the close icon and make it a bit more subtle
To make the modal more refined, I have swapped the standard button for a subtle "×" (multiplication sign) positioned in the top-right corner. It uses a transparent background and a hover transition to stay out of the way of the content.
I have also kept the <dialog> element and the <form method="dialog"> pattern, which ensures that the Escape key and focus management work natively.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Subtle Accessible Gallery</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.5;
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
background-color: #fafafa;
color: #333;
}
h2 { font-weight: 400; margin-top: 3rem; color: #666; }
.intro { margin-bottom: 2rem; color: #888; }
</style>
</head>
<body>
<h1>Image Gallery Component</h1>
<p class="intro">Click images to expand. Responsive grid and native keyboard accessibility included.</p>
<h2>Width: 3 (Default)</h2>
<image-gallery>
<img src="https://picsum.photos/1200/800?random=1" alt="Mountain Lake">
<img src="https://picsum.photos/1200/800?random=2" alt="City Lights" data-thumb="https://picsum.photos/300/200?random=2">
<img src="https://picsum.photos/1200/800?random=3" alt="Deep Forest">
</image-gallery>
<h2>Width: 5</h2>
<image-gallery width="5">
<img src="https://picsum.photos/1200/800?random=10" alt="Sample 1">
<img src="https://picsum.photos/1200/800?random=11" alt="Sample 2">
<img src="https://picsum.photos/1200/800?random=12" alt="Sample 3">
<img src="https://picsum.photos/1200/800?random=13" alt="Sample 4">
<img src="https://picsum.photos/1200/800?random=14" alt="Sample 5" data-thumb="https://picsum.photos/300/200?random=14">
<img src="https://picsum.photos/1200/800?random=15" alt="Sample 6">
<img src="https://picsum.photos/1200/800?random=16" alt="Sample 7">
</image-gallery>
<!-- Implementation -->
<script>
class ImageGallery extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
static get observedAttributes() {
return ['width'];
}
attributeChangedCallback() {
this.render();
}
connectedCallback() {
this.render();
}
handleImageClick(e) {
const img = e.target.closest('img');
if (!img) return;
const dialog = this.shadowRoot.querySelector('dialog');
const modalImg = dialog.querySelector('.modal-img');
modalImg.src = img.dataset.fullsize;
modalImg.alt = img.alt;
dialog.showModal();
}
render() {
const cols = this.getAttribute('width') || 3;
this.shadowRoot.innerHTML = `
<style>
:host {
display: grid;
grid-template-columns: repeat(${cols}, 1fr);
gap: 10px;
width: 100%;
}
::slotted(img) {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
cursor: pointer;
display: block;
transition: filter 0.2s ease;
}
::slotted(img:hover) {
filter: contrast(1.1) brightness(0.9);
}
dialog {
padding: 0;
border: none;
background: transparent;
max-width: 90vw;
max-height: 90vh;
outline: none;
}
dialog::backdrop {
background: rgba(0, 0, 0, 0.8);
}
.modal-container {
position: relative;
display: flex;
justify-content: center;
align-items: center;
}
.modal-img {
max-width: 100%;
max-height: 90vh;
display: block;
box-shadow: 0 20px 50px rgba(0,0,0,0.5);
}
.close-btn {
position: absolute;
top: 10px;
right: 10px;
background: none;
border: none;
color: white;
font-size: 2.5rem;
line-height: 1;
cursor: pointer;
opacity: 0.5;
transition: opacity 0.2s;
padding: 0;
width: 44px;
height: 44px;
text-shadow: 0 0 10px rgba(0,0,0,0.5);
}
.close-btn:hover {
opacity: 1;
}
/* Hide scrollbar when dialog is open */
:host-context(body:has(dialog[open])) {
overflow: hidden;
}
</style>
<slot id="gallery-slot"></slot>
<dialog>
<div class="modal-container">
<form method="dialog">
<button class="close-btn" aria-label="Close modal">×</button>
</form>
<img class="modal-img" src="" alt="">
</div>
</dialog>
`;
this.setupImages();
const dialog = this.shadowRoot.querySelector('dialog');
// Close if clicking the backdrop (the area outside the image)
dialog.addEventListener('click', (e) => {
if (e.target === dialog) dialog.close();
});
}
setupImages() {
const slot = this.shadowRoot.querySelector('#gallery-slot');
const images = slot.assignedElements();
images.forEach(img => {
if (img.tagName === 'IMG') {
// Store the original source for full size display
if (!img.dataset.fullsize) {
img.dataset.fullsize = img.src;
}
// If data-thumb is provided, replace the source in the grid
if (img.dataset.thumb) {
img.src = img.dataset.thumb;
}
img.onclick = (e) => this.handleImageClick(e);
}
});
}
}
customElements.define('image-gallery', ImageGallery);
</script>
</body>
</html>- Escape Key: Works automatically to close the gallery thanks to the native
<dialog>element. - Subtle "X": The close button is a simple multiplication sign (
×) withopacity: 0.5. It becomes fully opaque only when hovered, ensuring the image remains the focus. - Backdrop Click: Added a small event listener so that clicking anywhere on the dark background also closes the modal.
- Aria-Label: The "X" button has an
aria-label="Close modal"so that screen reader users understand its purpose. - Focus Trapping: When the modal is open, the browser natively prevents the user from tabbing out of the modal into the background page.
4,319 input, 2,516 output, {"candidatesTokenCount": 2050, "cachedContentTokenCount": 2030, "promptTokensDetails": [{"modality": "TEXT", "tokenCount": 4319}], "cacheTokensDetails": [{"modality": "TEXT", "tokenCount": 2030}], "thoughtsTokenCount": 466}
remove the hover effect entirely
Here is the updated implementation. I have removed all hover transitions and visual changes (brightness, contrast, and opacity shifts) from both the gallery images and the modal's close button.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Accessible Image Gallery</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.5;
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
background-color: #ffffff;
color: #333;
}
h2 { font-weight: 400; margin-top: 3rem; color: #666; }
</style>
</head>
<body>
<h1>Image Gallery Component</h1>
<h2>Gallery: Width 3</h2>
<image-gallery>
<img src="https://picsum.photos/1200/800?random=1" alt="Mountain Lake">
<img src="https://picsum.photos/1200/800?random=2" alt="City Lights" data-thumb="https://picsum.photos/300/200?random=2">
<img src="https://picsum.photos/1200/800?random=3" alt="Deep Forest">
</image-gallery>
<h2>Gallery: Width 5</h2>
<image-gallery width="5">
<img src="https://picsum.photos/1200/800?random=10" alt="Sample 1">
<img src="https://picsum.photos/1200/800?random=11" alt="Sample 2">
<img src="https://picsum.photos/1200/800?random=12" alt="Sample 3" data-thumb="https://picsum.photos/300/200?random=12">
<img src="https://picsum.photos/1200/800?random=13" alt="Sample 4">
<img src="https://picsum.photos/1200/800?random=14" alt="Sample 5">
<img src="https://picsum.photos/1200/800?random=15" alt="Sample 6">
<img src="https://picsum.photos/1200/800?random=16" alt="Sample 7">
</image-gallery>
<!-- Web Component Implementation -->
<script>
class ImageGallery extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
static get observedAttributes() {
return ['width'];
}
attributeChangedCallback() {
this.render();
}
connectedCallback() {
this.render();
}
handleImageClick(e) {
const img = e.target.closest('img');
if (!img) return;
const dialog = this.shadowRoot.querySelector('dialog');
const modalImg = dialog.querySelector('.modal-img');
modalImg.src = img.dataset.fullsize;
modalImg.alt = img.alt;
dialog.showModal();
}
render() {
const cols = this.getAttribute('width') || 3;
this.shadowRoot.innerHTML = `
<style>
:host {
display: grid;
grid-template-columns: repeat(${cols}, 1fr);
gap: 10px;
width: 100%;
}
::slotted(img) {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
cursor: pointer;
display: block;
}
/* Dialog (Modal) Styling */
dialog {
padding: 0;
border: none;
background: transparent;
max-width: 95vw;
max-height: 95vh;
outline: none;
}
dialog::backdrop {
background: rgba(0, 0, 0, 0.85);
}
.modal-container {
position: relative;
display: flex;
justify-content: center;
align-items: center;
}
.modal-img {
max-width: 100%;
max-height: 95vh;
display: block;
}
.close-btn {
position: absolute;
top: 10px;
right: 10px;
background: none;
border: none;
color: white;
font-size: 2.5rem;
line-height: 1;
cursor: pointer;
padding: 0;
width: 44px;
height: 44px;
opacity: 0.7; /* Subtle but static */
text-shadow: 0 0 10px rgba(0,0,0,0.5);
}
/* No hover effects allowed as requested */
</style>
<slot id="gallery-slot"></slot>
<dialog>
<div class="modal-container">
<form method="dialog">
<button class="close-btn" aria-label="Close modal">×</button>
</form>
<img class="modal-img" src="" alt="">
</div>
</dialog>
`;
this.setupImages();
const dialog = this.shadowRoot.querySelector('dialog');
// Close if clicking the backdrop
dialog.addEventListener('click', (e) => {
if (e.target === dialog) dialog.close();
});
}
setupImages() {
const slot = this.shadowRoot.querySelector('#gallery-slot');
const images = slot.assignedElements();
images.forEach(img => {
if (img.tagName === 'IMG') {
// Store full size source
if (!img.dataset.fullsize) {
img.dataset.fullsize = img.src;
}
// Use thumbnail if provided
if (img.dataset.thumb) {
img.src = img.dataset.thumb;
}
img.onclick = (e) => this.handleImageClick(e);
}
});
}
}
customElements.define('image-gallery', ImageGallery);
</script>
</body>
</html>- Removed Image Hover: The
::slotted(img:hover)styles and thetransitionproperties have been deleted. - Removed Button Hover: The
.close-btn:hoverstyles were removed. The "X" icon now has a constant0.7opacity to remain subtle without changing when the mouse moves over it. - Clean CSS: Removed all filter/scale/brightness logic to ensure the visual state is entirely static until clicked.
6,376 input, 2,094 output, {"candidatesTokenCount": 1760, "cachedContentTokenCount": 4066, "promptTokensDetails": [{"modality": "TEXT", "tokenCount": 6376}], "cacheTokensDetails": [{"modality": "TEXT", "tokenCount": 4066}], "thoughtsTokenCount": 334}
I want no border on the close icon even when it is focused
To remove the focus ring (the "border" that appears when navigating via keyboard), we set outline: none; on the button's focus state.
In this version, the close icon remains entirely static and clean, even when it is the active element during keyboard navigation.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Clean Image Gallery</title>
<style>
body {
font-family: -apple-system, system-ui, sans-serif;
line-height: 1.5;
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
background-color: #ffffff;
}
h2 { font-weight: 400; color: #666; margin-top: 2rem; }
</style>
</head>
<body>
<h1>Native Image Gallery</h1>
<image-gallery width="4">
<img src="https://picsum.photos/1200/800?random=21" alt="Image 1">
<img src="https://picsum.photos/1200/800?random=22" alt="Image 2" data-thumb="https://picsum.photos/300/200?random=22">
<img src="https://picsum.photos/1200/800?random=23" alt="Image 3">
<img src="https://picsum.photos/1200/800?random=24" alt="Image 4">
</image-gallery>
<script>
class ImageGallery extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
static get observedAttributes() {
return ['width'];
}
attributeChangedCallback() {
this.render();
}
connectedCallback() {
this.render();
}
handleImageClick(e) {
const img = e.target.closest('img');
if (!img) return;
const dialog = this.shadowRoot.querySelector('dialog');
const modalImg = dialog.querySelector('.modal-img');
modalImg.src = img.dataset.fullsize;
modalImg.alt = img.alt;
dialog.showModal();
}
render() {
const cols = this.getAttribute('width') || 3;
this.shadowRoot.innerHTML = `
<style>
:host {
display: grid;
grid-template-columns: repeat(${cols}, 1fr);
gap: 10px;
width: 100%;
}
::slotted(img) {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
cursor: pointer;
display: block;
}
dialog {
padding: 0;
border: none;
background: transparent;
max-width: 95vw;
max-height: 95vh;
/* Removes the focus ring from the dialog element itself in some browsers */
outline: none;
}
dialog::backdrop {
background: rgba(0, 0, 0, 0.85);
}
.modal-container {
position: relative;
display: flex;
justify-content: center;
align-items: center;
}
.modal-img {
max-width: 100%;
max-height: 95vh;
display: block;
}
.close-btn {
position: absolute;
top: 10px;
right: 10px;
background: none;
border: none;
color: white;
font-size: 2.5rem;
line-height: 1;
cursor: pointer;
padding: 0;
width: 44px;
height: 44px;
opacity: 0.6;
text-shadow: 0 0 10px rgba(0,0,0,0.5);
/* Remove border/outline on focus */
outline: none;
}
/* Explicitly ensure no focus ring appears on the button */
.close-btn:focus,
.close-btn:active,
.close-btn:focus-visible {
outline: none;
border: none;
box-shadow: none;
}
</style>
<slot id="gallery-slot"></slot>
<dialog>
<div class="modal-container">
<form method="dialog">
<button class="close-btn" aria-label="Close modal">×</button>
</form>
<img class="modal-img" src="" alt="">
</div>
</dialog>
`;
this.setupImages();
const dialog = this.shadowRoot.querySelector('dialog');
dialog.addEventListener('click', (e) => {
if (e.target === dialog) dialog.close();
});
}
setupImages() {
const slot = this.shadowRoot.querySelector('#gallery-slot');
const images = slot.assignedElements();
images.forEach(img => {
if (img.tagName === 'IMG') {
if (!img.dataset.fullsize) {
img.dataset.fullsize = img.src;
}
if (img.dataset.thumb) {
img.src = img.dataset.thumb;
}
img.onclick = (e) => this.handleImageClick(e);
}
});
}
}
customElements.define('image-gallery', ImageGallery);
</script>
</body>
</html>.close-btn:focus-visible: Browsers typically show a "focus ring" when a button is selected via the keyboard. By settingoutline: none;andbox-shadow: none;on:focusand:focus-visible, we ensure the "X" remains perfectly clean with no border or highlights regardless of how it is accessed.- Static UI: No hover states or transitions are present on the gallery or the close button.
8,151 input, 1,806 output, {"candidatesTokenCount": 1559, "promptTokensDetails": [{"modality": "TEXT", "tokenCount": 8151}], "thoughtsTokenCount": 247}
Here's that final attempt rendered via a Gist preview: https://gistpreview.github.io/?42ba73e7a6f7b67ad57be0f5014bab72