what we're building
two pieces sitting side by side: a grid of clickable items on one side, and a panel on the other that displays whichever item was last clicked. the panel starts out showing a placeholder ("select an item to see its details"), and updates every time a different item in the grid is clicked, also visually marking that item as the selected one.
option 1: a javascript data array (best for lots of shared fields)
this method keeps all your item data in one place, a javascript array, completely separate from the html. each grid item only needs a small data-id to say which entry it represents, javascript looks up the rest.
live example: click a swatch below.
select an item to see its details ⋆
the html only needs two containers, the gallery and the panel, plus one small attribute per item:
<div class="gallery" id="gallery">
<div class="gallery-item" data-id="1">
<img src="lorem.jpg">
<span>lorem</span>
</div>
<div class="gallery-item" data-id="2">
<img src="ipsum.jpg">
<span>ipsum</span>
</div>
<!-- one .gallery-item per entry, same data-id values used in the array below -->
</div>
<div class="panel" id="panel">
<p class="placeholder">select an item to see its details ⋆</p>
</div>
data-id="1": the only link between an item's thumbnail and its full data. it doesn't have to be a number, it just has to be unique per item and match a value in the javascript array below- the
<p class="placeholder">already sitting inside#panelis the default view. no javascript has to run for it to show up, it's just regular html that's there from the start, and gets swapped out the first time something is clicked
the actual item data lives entirely in javascript, as an array of objects:
const items = [
{ id: "1", name: "lorem", category: "type a", rating: "★★★★☆", color: "#ffd8e9", note: "lorem ipsum dolor sit amet, consectetur adipiscing elit." },
{ id: "2", name: "ipsum", category: "type b", rating: "★★★☆☆", color: "#d8f0ff", note: "sed do eiusmod tempor incididunt ut labore et dolore magna." },
{ id: "3", name: "dolor", category: "type a", rating: "★★★★★", color: "#e2d8ff", note: "ut enim ad minim veniam, quis nostrud exercitation ullamco." }
// ...one object per item, in whatever order you like
];
- each object is one item, and can hold as many fields as you actually need, a name, a category, a rating, an image path, a longer note, whatever your panel wants to display. this is the big advantage of the array method over option 2 below: adding a new field means adding one key to each object, not touching the html at all
- the
idfield inside each object is what gets matched against a clicked item'sdata-id, so keep these two in sync, whatever value you use in the html has to appear here too
and finally, the click handler that ties the two together:
const gallery = document.getElementById("gallery");
const panel = document.getElementById("panel");
gallery.addEventListener("click", function (event) {
const clicked = event.target.closest(".gallery-item");
if (!clicked) return;
const item = items.find(function (entry) {
return entry.id === clicked.dataset.id;
});
if (!item) return;
gallery.querySelectorAll(".gallery-item").forEach(function (el) {
el.classList.remove("selected");
});
clicked.classList.add("selected");
panel.innerHTML = `
<div class="swatch" style="background:${item.color}"></div>
<h4>${item.name}</h4>
<div class="sub">${item.category} · ${item.rating}</div>
<p>${item.note}</p>
`;
});
gallery.addEventListener("click", ...): the listener is attached once to the whole gallery container, not once per individual item. this is called event delegation, clicking any item inside the gallery bubbles up and triggers this one listener, which then figures out which specific item was actually clicked. it's less code than looping through every item and attaching a separate listener to each one, and it automatically keeps working even if items get added or removed laterevent.target.closest(".gallery-item"): since the gallery could contain nested elements (an image, a span, and so on), a click might technically land on one of those inner elements rather than the outer.gallery-itemitself..closest()walks upward from whatever was actually clicked until it finds the nearest ancestor matching that selector, so it doesn't matter if someone clicks the thumbnail image or the label text, both resolve to the same itemif (!clicked) return;: a safety check, if the click happened somewhere in the gallery but not on an actual item (empty space between items, for example),.closest()returnsnulland this stops the function early instead of erroring out on the next lineitems.find(function (entry) { return entry.id === clicked.dataset.id; }): searches the array for the one object whoseidmatches the clicked element'sdata-id..find()returns the first matching object, orundefinedif nothing matches, which the next line'sif (!item) return;guards against- the two lines removing then re-adding the
selectedclass: clear it off every item first, then add it back to only the one that was just clicked, this is the same "clear everything, then set the one" pattern used for tabs or any other single-selection ui panel.innerHTML = \`...\`: replaces the panel's entire contents, including that original placeholder paragraph, with a small html template built from the matched item's data. this is a template literal (backticks instead of quotes), which lets${item.name}style placeholders get filled in directly, without needing to glue strings together with a bunch of+signs
that last line is doing more than it looks like at first glance, so it's worth slowing down on:
panel.innerHTML = `
<div class="swatch" style="background:${item.color}"></div>
<h4>${item.name}</h4>
<div class="sub">${item.category} · ${item.rating}</div>
<p>${item.note}</p>
`;
panel.innerHTML(the left side):innerHTMLis a property every element has, reading it gives you that element's contents as an html string, and writing to it does the opposite, it takes a string of html and renders it as real, live elements inside that container, completely replacing whatever was there before. that "replacing" part matters, this single line is what makes the old placeholder<p>disappear, it isn't hidden with css or removed with a separate step, it's simply gone the moment something new is assigned toinnerHTML- the backticks (
\`) instead of regular quotes ("or'): this is what makes it a template literal, a special kind of javascript string with two extra powers that a normal quoted string doesn't have. regular strings can't contain a real line break without breaking your code, but template literals can, which is why this one is written across five lines and still works fine, every line break inside the backticks becomes part of the string itself - the
${...}parts, called interpolation: anything written inside${ }is treated as actual javascript, evaluated, and the result gets dropped into the string at that exact spot.${item.color}doesn't literally insert the text "item.color", it runsitem.coloras code, gets back whatever string that property actually holds (something like"#ffd8e9"), and stitches that value in. this happens separately for every${ }in the template, so all four,item.color,item.name,item.category,item.rating, anditem.note, get swapped out with that specific clicked item's own data - everything outside the
${ }parts is just plain text, copied through exactly as written, that's the<div class="swatch" style="background:, the closing"></div>, the<h4>tags, and so on. the browser doesn't know or care that this string was built dynamically, once it's assigned toinnerHTMLit's parsed exactly like any other chunk of html would be - the middle dot (
·) between${item.category}and${item.rating}is nothing special, it's just a literal character sitting in the template like any other bit of plain text, purely decorative, there to visually separate the two values once they're both filled in - the indentation and blank-looking space around each line inside the backticks does get included in the final string, since template literals preserve whitespace exactly as typed. this is harmless here, browsers collapse extra whitespace when rendering html, but it's worth knowing it's not being trimmed automatically, if you ever inspect
panel.innerHTMLdirectly in the console you'll see those leading spaces and line breaks are really there - put simply, this whole line reads as "take this chunk of html, but wherever there's a
${ }, swap in this item's actual value first, then render the result inside the panel", one single template covers every possible item, only the four filled-in values change from click to click
put together, in the exact order it should sit inside your <script> tag, here's the complete option 1 script:
const items = [
{ id: "1", name: "lorem", category: "type a", rating: "★★★★☆", color: "#ffd8e9", note: "lorem ipsum dolor sit amet, consectetur adipiscing elit." },
{ id: "2", name: "ipsum", category: "type b", rating: "★★★☆☆", color: "#d8f0ff", note: "sed do eiusmod tempor incididunt ut labore et dolore magna." },
{ id: "3", name: "dolor", category: "type a", rating: "★★★★★", color: "#e2d8ff", note: "ut enim ad minim veniam, quis nostrud exercitation ullamco." }
// ...one object per item, in whatever order you like
];
const gallery = document.getElementById("gallery");
const panel = document.getElementById("panel");
gallery.addEventListener("click", function (event) {
const clicked = event.target.closest(".gallery-item");
if (!clicked) return;
const item = items.find(function (entry) {
return entry.id === clicked.dataset.id;
});
if (!item) return;
gallery.querySelectorAll(".gallery-item").forEach(function (el) {
el.classList.remove("selected");
});
clicked.classList.add("selected");
panel.innerHTML = `
<div class="swatch" style="background:${item.color}"></div>
<h4>${item.name}</h4>
<div class="sub">${item.category} · ${item.rating}</div>
<p>${item.note}</p>
`;
});
- the
itemsarray has to be declared before the click handler that uses it, that's why it comes first here, javascript reads top to bottom anditems.find(...)further down needsitemsto already exist by the time it runs galleryandpanelonly need to be looked up once each, right after the array, both get reused every single time the listener fires, there's no need to callgetElementByIdagain inside the click handler itself- everything from
gallery.addEventListeneronward only actually runs once someone clicks, the code just sits there waiting, it doesn't execute top to bottom the way the twoconstlines above it do
innerHTML is being used here, never build a template like this out of raw user-submitted text without cleaning it first, anything typed by a visitor and inserted this way could include actual html or script tags. for your own hand-written item data (like a personal collection), this isn't a concern.option 2: data attributes directly on each item (best for a handful of fields)
when each item only needs a couple of short fields, keeping a whole separate javascript array can be more setup than it's worth. this version skips the array entirely and reads the data straight off the clicked element's own attributes instead.
live example: click an item below.
select an item to see its details ⋆
<div class="gallery" id="gallery">
<div class="gallery-item" data-title="lorem ipsum" data-note="dolor sit amet, consectetur adipiscing elit.">lorem ipsum</div>
<div class="gallery-item" data-title="sed do eiusmod" data-note="tempor incididunt ut labore et dolore magna aliqua.">sed do eiusmod</div>
</div>
<div class="panel" id="panel">
<p class="placeholder">select an item to see its details ⋆</p>
</div>
- no
data-idneeded this time, the fields themselves (data-title,data-note) are the data, written directly onto the element that will be clicked. there's nothing anywhere else on the page to keep in sync with - this scales worse than option 1 the more fields you add, three or four
data-attributes on one element is fine, ten starts getting hard to read and edit by hand
const gallery = document.getElementById("gallery");
const panel = document.getElementById("panel");
gallery.addEventListener("click", function (event) {
const clicked = event.target.closest(".gallery-item");
if (!clicked) return;
gallery.querySelectorAll(".gallery-item").forEach(function (el) {
el.classList.remove("selected");
});
clicked.classList.add("selected");
panel.innerHTML = `
<h4>${clicked.dataset.title}</h4>
<p>${clicked.dataset.note}</p>
`;
});
- the event delegation and "clear then select" steps are identical to option 1, that part of the logic doesn't change no matter where the data comes from
- the only real difference is this line: instead of
items.find(...)searching a separate array,clicked.dataset.titleandclicked.dataset.noteread the values straight off the element that was clicked. no lookup step needed, because the data was never anywhere else to begin with
the default placeholder state
notice that in both demos above, the placeholder paragraph is written directly into the html, sitting inside the panel from the moment the page loads, it isn't something javascript inserts on page load, javascript only ever replaces it, and only once something gets clicked. this matters for one practical reason: if javascript fails to load for any reason, a broken script elsewhere on the page, a typo, a slow connection, the panel still shows a sensible message instead of sitting completely blank.
going back to the default view
selecting an item is only half the pattern, most collections also want a way back to that empty placeholder state. two common ways to do it: a dedicated reset button, or letting visitors click the already-selected item a second time to deselect it. both rely on the exact same trick: saving a copy of the placeholder's original html before anything gets overwritten, so it can be put back later.
method a: a reset button
a plain button sitting near the panel (or the gallery), that clears the current selection and restores the placeholder when clicked.
live example: select an item, then hit reset.
select an item to see its details ⋆
<div class="panel" id="panel">
<p class="placeholder">select an item to see its details ⋆</p>
</div>
<button type="button" id="resetBtn">reset ↺</button>
- the button can live anywhere on the page, next to the panel, next to the gallery, wherever makes sense visually, its position doesn't affect how the script below works
type="button": same reason as the gallery items earlier, this stops it from accidentally acting as a form submit button if it's ever placed inside a<form>
const gallery = document.getElementById("gallery");
const panel = document.getElementById("panel");
const resetBtn = document.getElementById("resetBtn");
const defaultPanelHTML = panel.innerHTML;
gallery.addEventListener("click", function (event) {
// ...same click handler from option 1 or option 2 above, unchanged
});
resetBtn.addEventListener("click", function () {
gallery.querySelectorAll(".gallery-item").forEach(function (el) {
el.classList.remove("selected");
});
panel.innerHTML = defaultPanelHTML;
});
const defaultPanelHTML = panel.innerHTML;: this is the whole trick, and it has to run before anything else has a chance to click on the gallery and overwrite the panel. readingpanel.innerHTMLat this point in the script captures exactly what's currently inside the panel, which is still the original placeholder paragraph from the html, as a plain string saved in a variable for later- the gallery's own click handler doesn't need any changes at all, it's the exact same code from option 1 or option 2, selecting an item still works normally
resetBtn.addEventListener("click", ...): a separate, independent listener on the reset button. clicking it does two things: clearsselectedoff every gallery item (so nothing looks highlighted anymore), then setspanel.innerHTMLback todefaultPanelHTML, the exact placeholder markup that was saved when the page first loaded
defaultPanelHTML is captured straight from the actual html rather than retyped as a string somewhere in the script, editing the placeholder's wording or markup only ever needs to happen in one place, the html itself, the reset button will always restore whatever is currently written there.method b: click the selected item again to deselect
instead of (or alongside) a reset button, clicking on an item that's already selected can toggle it back off, no extra button needed at all. this only takes a small addition to the click handler that already exists.
live example: click an item to select it, click it again to deselect.
select an item to see its details ⋆
const gallery = document.getElementById("gallery");
const panel = document.getElementById("panel");
const defaultPanelHTML = panel.innerHTML;
gallery.addEventListener("click", function (event) {
const clicked = event.target.closest(".gallery-item");
if (!clicked) return;
const alreadySelected = clicked.classList.contains("selected");
gallery.querySelectorAll(".gallery-item").forEach(function (el) {
el.classList.remove("selected");
});
if (alreadySelected) {
panel.innerHTML = defaultPanelHTML;
return;
}
clicked.classList.add("selected");
panel.innerHTML = `
<h4>${clicked.dataset.title}</h4>
<p>${clicked.dataset.note}</p>
`;
});
const alreadySelected = clicked.classList.contains("selected");: checks, before anything else changes, whether the item that was just clicked was already the selected one. this has to be read before the next step clears every item'sselectedclass, otherwise there'd be nothing left to check- the
forEachclearingselectedoff every item still runs first no matter what, exactly like before, this keeps the "clear everything, then decide" order consistent regardless of which branch runs next if (alreadySelected) { panel.innerHTML = defaultPanelHTML; return; }: if the clicked item was already selected, its highlight has just been cleared by the line above, the panel is reset back to the saved placeholder, andreturnstops the function here, skipping the rest below that would otherwise re-select it- if the item wasn't already selected, execution falls through past that
ifblock entirely, and the normal select-and-fill-the-panel code underneath runs exactly as it did before this method was added
dblclick event. that's intentional, it's more discoverable (visitors don't have to guess that double-clicking does something special) and works identically on touchscreens, where true double-clicks/double-taps are unreliable. if you specifically want a literal double-click gesture instead, swap "click" for "dblclick" in the addEventListener call, though method a's reset button or this single-click toggle are the more common choices for this kind of ui.which one should you use?
- use option 1 (data array) when items have several fields, when you'd rather edit a clean list of objects than a wall of html attributes, or when the same data might need to be reused elsewhere on the page (a search bar, a sorting feature, and so on all read from the same array)
- use option 2 (data attributes) when each item only needs one or two short fields, or when you're working directly in html and don't want the extra step of maintaining a separate javascript array
- add method a (reset button) when you want an obvious, always-visible way back to the default view, especially useful if the gallery is long and scrolling back up to re-click the selected item would be annoying
- add method b (click again to deselect) when you want deselecting to feel effortless and don't want to spend extra space on a dedicated button, it also pairs nicely with method a, nothing stops you from offering both at once
result
the whole pattern boils down to three moving parts: a grid of items each carrying (or pointing to) their own data, one click listener on the grid using event delegation so it works no matter how many items there are, and a panel that starts with a placeholder and gets its contents replaced on every click. getting back to that placeholder is just the same idea run in reverse, a saved copy of the original placeholder html, restored either by a button or by clicking the selected item again. everything else, how the panel is styled, what fields each item has, whether the data lives in an array or in attributes, is just detail on top of that same core structure.