{%- comment -%} MIT License · (c) 2026 Lintel · https://lintelagency.com/resources/shopify-sections {%- endcomment -%} {%- comment -%} Recently viewed products: cards for the products this shopper looked at, newest first. On a product page the section notes the product's handle in the browser's localStorage (nothing else, and nothing leaves the device); wherever the section is placed it reads those handles, asks the store for each product's current details (/products/.js) and builds the cards. Products that were deleted drop out of the list. The section stays hidden until there is something to show, and a "clear history" button empties the list. Without JavaScript it renders nothing. Depends on: snippets/lib-product-card.liquid, snippets/lib-image.liquid {%- endcomment -%} {%- liquid assign s = section.settings assign heading_tag = s.heading_tag | default: 'h2' assign card_tag = 'h3' if heading_tag == 'h3' or heading_tag == 'p' assign card_tag = 'h4' endif assign cols = s.columns | plus: 0 assign cols_m = s.columns_mobile | plus: 0 assign img_vw = 100 | divided_by: cols assign img_sizes = '(min-width: 990px) ' | append: img_vw | append: 'vw, (min-width: 750px) 33vw, 50vw' assign current_handle = '' if product != blank assign current_handle = product.handle endif assign list_hidden = false if product != blank and s.show_on_product_pages == false assign list_hidden = true endif -%} {% stylesheet %} .lib-recent { display: block; background-color: var(--lib-bg); background-image: var(--lib-bg-image, none); color: var(--lib-text); padding: var(--lib-pt) 1rem var(--lib-pb); } .lib-recent[hidden] { display: none; } .lib-recent__inner { max-width: 1200px; margin: 0 auto; } .lib-recent--full .lib-recent__inner { max-width: none; } @media (max-width: 749px) { .lib-recent { padding-top: var(--lib-pt-m); padding-bottom: var(--lib-pb-m); } .lib-recent[data-show='desktop'] { display: none; } } @media (min-width: 750px) { .lib-recent[data-show='mobile'] { display: none; } } .lib-recent__header { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 0.75rem 1.5rem; margin-bottom: clamp(1.25rem, 3vw, 2rem); } .lib-recent__copy { min-width: 0; max-width: 40rem; } .lib-recent__heading { margin: 0; font-size: clamp(1.5rem, 2.6vw, 2.125rem); line-height: 1.15; letter-spacing: -0.02em; text-wrap: balance; } .lib-recent__heading--small { font-size: clamp(1.125rem, 1.6vw, 1.375rem); } .lib-recent__heading--large { font-size: clamp(2rem, 4vw, 3rem); } .lib-recent__text { margin-top: 0.5rem; font-size: 1rem; line-height: 1.6; opacity: 0.85; } .lib-recent__text > :first-child { margin-top: 0; } .lib-recent__text > :last-child { margin-bottom: 0; } .lib-recent__header[data-align='center'] { flex-direction: column; align-items: center; text-align: center; } @media (max-width: 749px) { .lib-recent__header[data-align-m='left'] { flex-direction: row; align-items: flex-end; text-align: left; } .lib-recent__header[data-align-m='center'] { flex-direction: column; align-items: center; text-align: center; } } .lib-recent__clear { min-height: 2.75rem; padding: 0.5rem 0.25rem; border: 0; background: none; color: inherit; font: inherit; font-size: 0.875rem; text-decoration: underline; text-underline-offset: 0.2em; cursor: pointer; } .lib-recent__clear:focus-visible { outline: 2px solid var(--lib-accent); outline-offset: 2px; border-radius: 4px; } .lib-recent__list { display: grid; grid-template-columns: repeat(var(--lib-cols), minmax(0, 1fr)); gap: clamp(1rem, 2vw, 1.5rem); margin: 0; padding: 0; list-style: none; } .lib-recent__item { display: flex; min-width: 0; } @media (max-width: 749px) { .lib-recent__list { grid-template-columns: repeat(var(--lib-cols-m), minmax(0, 1fr)); } /* Row: one swipeable line with a peek of the next card. */ .lib-recent[data-layout-m='row'] .lib-recent__list { grid-auto-flow: column; grid-template-columns: none; grid-auto-columns: calc((100% - 1rem) / var(--lib-cols-m) - 2.5rem); overflow-x: auto; scroll-snap-type: x mandatory; scrollbar-width: none; padding-bottom: 0.25rem; } .lib-recent[data-layout-m='row'] .lib-recent__item { scroll-snap-align: start; } } .lib-recent__status:empty { display: none; } .lib-recent__status { margin: 0; font-size: 0.9375rem; opacity: 0.85; } @media (prefers-reduced-motion: no-preference) { .lib-recent[data-animate] .lib-recent__item[data-new] { animation: lib-recent-in 0.5s cubic-bezier(0.2, 0.7, 0.2, 1) both; } } @keyframes lib-recent-in { from { opacity: 0; transform: translateY(12px); } } {% endstylesheet %} {% javascript %} (() => { if (customElements.get('lib-recently-viewed')) return; // One list per store, newest first. Only product handles are kept, never personal data. const STORAGE_KEY = 'lib-recently-viewed'; const MAX_STORED = 24; const IMAGE_WIDTHS = [300, 600, 900]; const readHandles = () => { try { const value = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); return Array.isArray(value) ? value.filter((h) => typeof h === 'string') : []; } catch (error) { // Storage blocked (private mode, policy) or a damaged value: start from nothing. return []; } }; const writeHandles = (handles) => { try { if (handles.length) localStorage.setItem(STORAGE_KEY, JSON.stringify(handles.slice(0, MAX_STORED))); else localStorage.removeItem(STORAGE_KEY); } catch (error) { // Storage full or blocked: the list simply isn't kept on this device. } }; const sized = (url, width) => `${url}${url.includes('?') ? '&' : '?'}width=${width}`; class LibRecentlyViewed extends HTMLElement { connectedCallback() { this.list = this.querySelector('[data-list]'); this.status = this.querySelector('[data-status]'); this.template = this.querySelector('[data-card-template]'); const meta = this.querySelector('[data-sizes]'); this.sizes = meta?.dataset.sizes || '50vw'; this.clearedText = meta?.dataset.cleared || ''; this.querySelector('[data-clear]')?.addEventListener('click', () => this.clear()); const current = this.dataset.current; const handles = readHandles(); if (current) writeHandles([current, ...handles.filter((h) => h !== current)]); if (this.hasAttribute('data-record-only')) return; const wanted = handles.filter((h) => h !== current).slice(0, Number(this.dataset.limit) || 4); if (wanted.length) this.load(wanted); } async load(handles) { const root = this.dataset.root || '/'; const results = await Promise.allSettled( handles.map(async (handle) => { const response = await fetch(`${root}products/${encodeURIComponent(handle)}.js`, {headers: {Accept: 'application/json'}}); if (response.status === 404) return {handle, gone: true}; if (!response.ok) throw new Error(`product ${handle}: ${response.status}`); return {handle, product: await response.json()}; }), ); const gone = results.filter((r) => r.status === 'fulfilled' && r.value.gone).map((r) => r.value.handle); if (gone.length) writeHandles(readHandles().filter((h) => !gone.includes(h))); results .filter((r) => r.status === 'rejected') .forEach((r) => console.warn('Recently viewed: a product could not be loaded.', r.reason)); const products = results.filter((r) => r.status === 'fulfilled' && r.value.product).map((r) => r.value.product); if (!products.length) return; this.list.querySelectorAll('[data-placeholder]').forEach((el) => el.remove()); this.list.append(...products.map((product) => this.card(product))); this.hidden = false; } price(cents) { try { return new Intl.NumberFormat(this.dataset.locale || undefined, {style: 'currency', currency: this.dataset.currency || 'USD'}).format(cents / 100); } catch (error) { // Unknown locale or currency code: a plain number still reads correctly. return (cents / 100).toFixed(2); } } card(product) { const item = this.template.content.firstElementChild.cloneNode(true); const slot = (name) => item.querySelector(`[data-slot="${name}"]`); const root = this.dataset.root || '/'; const link = slot('link'); link.href = `${root}products/${product.handle}`; link.textContent = product.title; const vendor = slot('vendor'); if (vendor) vendor.textContent = product.vendor || ''; const image = slot('image'); const src = product.featured_image; if (src) { image.src = sized(src, IMAGE_WIDTHS[1]); image.srcset = IMAGE_WIDTHS.map((w) => `${sized(src, w)} ${w}w`).join(', '); image.sizes = this.sizes; image.alt = product.media?.[0]?.alt || product.title; image.hidden = false; } const onSale = product.compare_at_price > product.price; slot('price').textContent = this.price(product.price); if (onSale) { slot('price').classList.add('lib-card__price-now--sale'); slot('compare').textContent = this.price(product.compare_at_price); ['compare', 'sale-label', 'regular-label'].forEach((name) => (slot(name).hidden = false)); } const card = item.querySelector('.lib-card'); if (!product.available) { card.classList.add('is-sold-out'); if (slot('sold-out').textContent.trim()) slot('sold-out').hidden = false; } else if (onSale && slot('sale').textContent.trim()) { slot('sale').hidden = false; } item.toggleAttribute('data-new', true); return item; } clear() { writeHandles([]); this.list.replaceChildren(); this.querySelector('[data-clear]')?.remove(); this.status.textContent = this.clearedText; if (!this.clearedText) this.hidden = true; } } customElements.define('lib-recently-viewed', LibRecentlyViewed); })(); {% endjavascript %} {% schema %} { "name": "Lib · Recently viewed", "tag": "section", "class": "lib-section", "settings": [ { "type": "paragraph", "content": "Add this section to the product template so viewed products are noted, then anywhere else you want the list. Only product handles are kept, in the shopper's browser." }, { "type": "header", "content": "Content" }, { "type": "inline_richtext", "id": "heading", "label": "Heading", "default": "Recently viewed" }, { "type": "select", "id": "heading_tag", "label": "Heading tag", "info": "Use H1 only when this is the page's main heading.", "options": [ { "value": "h1", "label": "H1" }, { "value": "h2", "label": "H2" }, { "value": "h3", "label": "H3" }, { "value": "p", "label": "Paragraph" } ], "default": "h2" }, { "type": "select", "id": "heading_size", "label": "Heading size", "options": [ { "value": "small", "label": "Small" }, { "value": "medium", "label": "Medium" }, { "value": "large", "label": "Large" } ], "default": "medium" }, { "type": "richtext", "id": "text", "label": "Intro text" }, { "type": "text", "id": "clear_label", "label": "Clear history button", "info": "Leave empty to hide the button.", "default": "Clear history" }, { "type": "text", "id": "cleared_label", "label": "Message after clearing", "default": "Your recently viewed list is now empty." }, { "type": "checkbox", "id": "show_on_product_pages", "label": "Show the list on product pages", "info": "When off, product pages only note the product; the list shows on other pages.", "default": true }, { "type": "header", "content": "Products" }, { "type": "range", "id": "products_to_show", "label": "Products to show", "min": 2, "max": 12, "step": 1, "default": 4 }, { "type": "select", "id": "image_ratio", "label": "Image shape", "options": [ { "value": "square", "label": "Square" }, { "value": "portrait", "label": "Portrait" }, { "value": "tall", "label": "Tall" }, { "value": "landscape", "label": "Landscape" } ], "default": "portrait" }, { "type": "select", "id": "card_style", "label": "Card style", "options": [ { "value": "plain", "label": "Plain" }, { "value": "card", "label": "Card" } ], "default": "plain" }, { "type": "range", "id": "card_radius", "label": "Corner radius", "min": 0, "max": 24, "step": 2, "unit": "px", "default": 12 }, { "type": "checkbox", "id": "show_vendor", "label": "Show vendor", "default": false }, { "type": "text", "id": "sale_label", "label": "Sale badge", "default": "Sale" }, { "type": "text", "id": "sold_out_label", "label": "Sold out badge", "default": "Sold out" }, { "type": "text", "id": "sale_price_label", "label": "Sale price label (screen readers)", "default": "Sale price" }, { "type": "text", "id": "regular_price_label", "label": "Regular price label (screen readers)", "default": "Regular price" }, { "type": "header", "content": "Layout" }, { "type": "range", "id": "columns", "label": "Columns on desktop", "min": 2, "max": 6, "step": 1, "default": 4 }, { "type": "range", "id": "columns_mobile", "label": "Columns on mobile", "min": 1, "max": 2, "step": 1, "default": 2 }, { "type": "select", "id": "layout_mobile", "label": "Layout on mobile", "options": [ { "value": "grid", "label": "Grid" }, { "value": "row", "label": "Swipeable row" } ], "default": "row" }, { "type": "select", "id": "section_width", "label": "Width", "options": [ { "value": "page", "label": "Page width" }, { "value": "full", "label": "Full width" } ], "default": "page" }, { "type": "select", "id": "text_alignment", "label": "Heading alignment on desktop", "options": [ { "value": "left", "label": "Left" }, { "value": "center", "label": "Center" } ], "default": "left" }, { "type": "select", "id": "text_alignment_mobile", "label": "Heading alignment on mobile", "options": [ { "value": "left", "label": "Left" }, { "value": "center", "label": "Center" } ], "default": "left" }, { "type": "header", "content": "Colors" }, { "type": "color", "id": "color_bg", "label": "Background", "default": "#FFFFFF" }, { "type": "color_background", "id": "color_bg_gradient", "label": "Background gradient", "info": "Optional. Covers the background color." }, { "type": "color", "id": "color_text", "label": "Text", "default": "#1F1B17" }, { "type": "color", "id": "color_accent", "label": "Accent (badges, focus)", "default": "#7A5A3A" }, { "type": "color", "id": "color_accent_text", "label": "Text on accent", "default": "#FFFFFF" }, { "type": "color", "id": "color_card", "label": "Card background", "default": "#FAF6F1" }, { "type": "header", "content": "Spacing and visibility" }, { "type": "range", "id": "padding_top", "label": "Top padding on desktop", "min": 0, "max": 200, "step": 4, "unit": "px", "default": 56 }, { "type": "range", "id": "padding_bottom", "label": "Bottom padding on desktop", "min": 0, "max": 200, "step": 4, "unit": "px", "default": 56 }, { "type": "range", "id": "padding_top_mobile", "label": "Top padding on mobile", "min": 0, "max": 160, "step": 4, "unit": "px", "default": 40 }, { "type": "range", "id": "padding_bottom_mobile", "label": "Bottom padding on mobile", "min": 0, "max": 160, "step": 4, "unit": "px", "default": 40 }, { "type": "select", "id": "show_on", "label": "Show on", "options": [ { "value": "all", "label": "All devices" }, { "value": "desktop", "label": "Desktop only" }, { "value": "mobile", "label": "Mobile only" } ], "default": "all" }, { "type": "text", "id": "anchor_id", "label": "Anchor ID", "info": "Optional. Link to this section with #your-id." }, { "type": "header", "content": "Motion" }, { "type": "checkbox", "id": "animate", "label": "Fade cards in as they load", "info": "Never when reduced motion is on.", "default": true } ], "presets": [{ "name": "Lib · Recently viewed" }] } {% endschema %}