Free
Features
Saltmere Features
A features block extracted from the Saltmere template, available in Tailwind CSS and Bootstrap.
0
2
A features block extracted from the Saltmere template, available in Tailwind CSS and Bootstrap.
<!DOCTYPE html>
<html lang="en">
<head>
<script>
window.FontAwesomeConfig = { autoReplaceSvg: 'nest' };
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/js/all.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Saltmere Features | Tailwind CSS UI Block</title>
<meta name="description" content="A features block extracted from the Saltmere template, available in Tailwind CSS and Bootstrap.">
<meta name="keywords" content="Saltmere, Tailwind CSS template, HTML website template, ecommerce template, skincare shop, grooming products, subscription box, beauty store website, product page, shopping cart">
<meta name="author" content="Let's templates">
<link rel="canonical" href="">
<meta property="og:type" content="product">
<meta property="og:site_name" content="Let's templates">
<meta property="og:locale" content="en_US">
<meta property="og:url" content="">
<meta property="og:title" content="Saltmere | Tailwind CSS Ecommerce Template">
<meta property="og:description" content="A warm, art-deco Tailwind CSS ecommerce template for grooming and skincare brands, with a monthly box builder, membership pricing and a working cart.">
<meta property="og:image" content="">
<meta property="og:image:alt" content="Saltmere template preview">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:url" content="">
<meta name="twitter:title" content="Saltmere | Tailwind CSS Ecommerce Template">
<meta name="twitter:description" content="A warm, art-deco Tailwind CSS ecommerce template for grooming and skincare brands, with a monthly box builder, membership pricing and a working cart.">
<meta name="twitter:image" content="">
<meta name="twitter:image:alt" content="Saltmere template preview">
<script src="https://cdn.tailwindcss.com/3.4.17"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Federo&family=Space+Grotesk:wght@400;500;700&display=swap" rel="stylesheet">
<script>
tailwind.config = {
theme: {
extend: {
colors: { sand: '#ecd5b5', peach: '#f3cdb8', cream: '#f5e6be', ink: '#1d1b19', rust: { DEFAULT: '#a8443a', dark: '#87322a' }, sage: '#617d58' },
fontFamily: { sans: ['"Space Grotesk"', 'sans-serif'], display: ['Federo', 'serif'] }
}
}
}
</script>
<style>
@view-transition { navigation: auto; }
::view-transition-old(root) { animation: 180ms ease-out both page-fade-out; }
::view-transition-new(root) { animation: 220ms ease-in both page-fade-in; }
@keyframes page-fade-out { to { opacity: 0; } }
@keyframes page-fade-in { from { opacity: 0; } }
html { scroll-behavior: smooth; }
[hidden] { display: none !important; }
body { background: radial-gradient(circle at 0% 0%, #f3c4b4 0, transparent 40%), radial-gradient(circle at 100% 100%, #f5e6be 0, transparent 45%), #ecd5b5; background-attachment: fixed; }
.grain { position: fixed; inset: 0; pointer-events: none; z-index: 60; opacity: .18; mix-blend-mode: multiply; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.6'/%3E%3C/svg%3E"); }
.scribble path { stroke-dasharray: 420; stroke-dashoffset: 0; transition: stroke-dashoffset .7s ease; }
.group:hover .scribble path, .group:focus-within .scribble path { stroke-dashoffset: 840; }
.rail { scrollbar-width: none; }
.rail::-webkit-scrollbar { display: none; }
.ring-fill { transition: stroke-dashoffset .7s ease; }
.hover-image { transition: opacity .3s ease, transform .3s ease; }
.reveal-ready [data-reveal] { opacity: 0; transform: translateY(24px); transition: opacity .6s ease, transform .6s ease; transition-delay: var(--reveal-delay, 0ms); }
.reveal-ready [data-reveal].is-visible { opacity: 1; transform: none; }
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
.scribble path, .ring-fill, .hover-image { transition: none; }
::view-transition-old(root), ::view-transition-new(root) { animation: none; }
}
</style>
</head>
<body class="font-sans text-ink antialiased">
<% active_page = "index" %>
<% nav = { "catalog" => "Catalog", "monthly-box" => "Monthly box", "about" => "About", "contact" => "Contact" } %>
<% %w[EN ES FR DE].each do |lang| %>
<% end %>
<% { "index" => "Home" }.merge(nav).merge("cart" => "Cart").each do |slug, label| %>
<% active = active_page == slug %>
<% end %>
<% products = [["sea-salt-wash", "Sea salt face wash", "Cleanser", 18, "1620917669809-1af0497965de", "Cream tube of face wash on a white background"], ["cedar-oil", "Cedar beard oil", "Beard", 24, "1617897903246-719242758050", "Glass dropper bottle of oil on a wooden board with eucalyptus"], ["night-serum", "Night repair serum", "Serum", 32, "1608571423902-eed4a5ad8108", "Amber serum bottle on a wooden stand"], ["daily-lotion", "Daily light lotion", "Moisturiser", 26, "1631729371254-42c2892f0e6e", "Three white pump bottles beside a plant"], ["lip-balm", "Lip & brow balm", "Balm", 12, "1599305090598-fe179d501227", "Small open jar of balm on a stone surface"], ["olive-clay", "Olive clay bar", "Soap", 10, "1614806687007-2215a9db3b1c", "Stacked green olive soap bars with dried flowers"], ["lavender-bar", "Lavender bar", "Soap", 9, "1600857544200-b2f666a9a2ec", "Handmade lavender soap bars tied with string"], ["gel-mask", "Cooling gel mask", "Mask", 22, "1619451334792-150fd785ee74", "Hands holding an open jar of blue gel mask"]] %>
<% products.each do |id, name, kind, price, photo, alt| %>
<% end %>
<% products.each do |id, name, kind, price, photo, alt| %>
<% end %>
<% 4.times do |i| %>
<% end %>
<% ingredients = [["Sea salt", "Gently buffs and wakes up tired skin.", "1556760544-74068565f05c", "Hands applying oil from a small dropper bottle"], ["Cedar", "Warm, woody and calming for beard and scalp.", "1617897903246-719242758050", "Dropper bottle of oil on a wooden board"], ["Oat milk", "Soothes razor burn and redness.", "1596755389378-c31d21fd1273", "Person in a face mask holding half an avocado"], ["Green clay", "Draws out oil without stripping.", "1614806687007-2215a9db3b1c", "Green clay soap bars stacked on linen"], ["Rosehip", "Rich in vitamin A for overnight repair.", "1600428877878-1a0fd85beda8", "Serum bottle with a stone face roller and gua sha"]] %>
<!-- block:start features -->
<section class="relative bg-ink py-24 text-cream" data-hover-list>
<div class="mx-auto max-w-[96rem] px-5 md:px-10">
<div class="flex flex-wrap items-end justify-between gap-6">
<h2 class="font-display text-5xl uppercase md:text-6xl">What goes in</h2>
<p class="max-w-sm text-cream/70">Five hero ingredients, sourced from small growers. Hover a row to see it at work.</p>
</div>
<ol class="mt-12 border-t border-cream/25">
<% ingredients.each_with_index do |(name, text, photo, alt), index| %>
<li>
<a href="catalog.html" data-hover-row data-image="https://images.unsplash.com/photo-<%= photo %>?auto=format&fit=crop&w=600&h=750&q=70" data-alt="<%= alt %>" class="group grid items-baseline gap-2 border-b border-cream/25 py-6 transition md:grid-cols-[5rem_1fr_1fr] md:gap-8 md:py-8">
<span class="text-sm text-cream/50">0<%= index + 1 %></span>
<span class="font-display text-4xl uppercase transition group-hover:translate-x-3 group-hover:text-peach group-focus-visible:text-peach md:text-7xl"><%= name %></span>
<span class="text-cream/75 md:text-right md:text-lg"><%= text %></span>
</a>
</li>
<% end %>
</ol>
</div>
<img data-hover-image src="https://images.unsplash.com/photo-<%= ingredients.first[2] %>?auto=format&fit=crop&w=600&h=750&q=70" alt="" class="hover-image pointer-events-none fixed left-0 top-0 z-30 hidden w-56 -rotate-3 border-4 border-cream opacity-0 shadow-2xl md:block" style="aspect-ratio: 4 / 5; object-fit: cover">
</section>
<!-- block:end features -->
<script>
const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
const watchMotion = !reducedMotion && !navigator.webdriver && 'IntersectionObserver' in window;
const store = { get: (key, fallback) => { try { return sessionStorage.getItem(key) ?? fallback; } catch (e) { return fallback; } }, set: (key, value) => { try { sessionStorage.setItem(key, value); } catch (e) {} } };
document.querySelectorAll('[data-menu-toggle]').forEach((button) => {
const menu = document.getElementById(button.getAttribute('aria-controls'));
const close = menu.querySelector('[data-menu-close]');
const set = (open) => { menu.hidden = !open; button.setAttribute('aria-expanded', String(open)); document.body.style.overflow = open ? 'hidden' : ''; (open ? close : button).focus(); };
button.addEventListener('click', () => set(menu.hidden));
close.addEventListener('click', () => set(false));
document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && !menu.hidden) set(false); });
});
document.querySelectorAll('[data-lang-toggle]').forEach((button) => {
const menu = document.getElementById(button.getAttribute('aria-controls'));
const set = (open) => { menu.hidden = !open; button.setAttribute('aria-expanded', String(open)); };
button.addEventListener('click', () => set(menu.hidden));
menu.querySelectorAll('[data-lang]').forEach((item) => item.addEventListener('click', () => { button.querySelector('[data-lang-label]').textContent = item.dataset.lang; set(false); button.focus(); }));
document.addEventListener('click', (event) => { if (!button.parentElement.contains(event.target)) set(false); });
});
const cartCount = () => Number(store.get('saltmere-cart', '2'));
const paintCart = () => document.querySelectorAll('[data-cart-count]').forEach((badge) => { badge.textContent = cartCount(); });
const addToCart = (amount) => { store.set('saltmere-cart', String(cartCount() + amount)); paintCart(); };
paintCart();
document.querySelectorAll('[data-add-to-cart]').forEach((button) => button.addEventListener('click', () => {
addToCart(Number(button.dataset.qty || 1));
const toast = document.querySelector('[data-cart-toast]');
if (toast) toast.textContent = `${button.dataset.name} added to your cart.`;
const label = button.textContent;
button.textContent = 'Added ✓';
setTimeout(() => { button.textContent = label; }, 1400);
}));
document.querySelectorAll('[data-rail-root]').forEach((root) => {
const track = root.querySelector('[data-rail-track]');
const step = () => track.firstElementChild.getBoundingClientRect().width + 20;
root.querySelector('[data-rail-prev]').addEventListener('click', () => track.scrollBy({ left: -step(), behavior: reducedMotion ? 'auto' : 'smooth' }));
root.querySelector('[data-rail-next]').addEventListener('click', () => track.scrollBy({ left: step(), behavior: reducedMotion ? 'auto' : 'smooth' }));
});
document.querySelectorAll('[data-box-builder]').forEach((root) => {
const max = Number(root.dataset.slots);
const discount = Number(root.dataset.discount);
const items = [...root.querySelectorAll('[data-box-item]')];
const slots = [...root.querySelectorAll('[data-box-slot]')];
const status = root.querySelector('[data-box-status]');
const add = root.querySelector('[data-box-add]');
let picked = [];
const money = (value) => `$${value.toFixed(2).replace(/\.00$/, '')}`;
const paint = () => {
items.forEach((item) => item.setAttribute('aria-pressed', String(picked.includes(item))));
slots.forEach((slot, i) => {
const item = picked[i];
slot.innerHTML = item ? `<img src="https://images.unsplash.com/photo-${item.dataset.photo}?auto=format&fit=crop&w=300&h=300&q=60" alt="${item.dataset.name}" class="h-full w-full object-cover">` : `Slot ${i + 1}`;
slot.classList.toggle('border-solid', !!item);
});
const full = picked.reduce((sum, item) => sum + Number(item.dataset.price), 0);
root.querySelector('[data-box-count]').textContent = picked.length;
root.querySelector('[data-box-progress]').style.width = `${(picked.length / max) * 100}%`;
root.querySelector('[data-box-full]').textContent = money(full);
root.querySelector('[data-box-saving]').textContent = `−${money(full * discount)}`;
root.querySelector('[data-box-total]').textContent = money(full * (1 - discount));
add.disabled = picked.length !== max;
status.textContent = picked.length === max ? 'Box full. Swap an item by tapping it again.' : `Pick ${max - picked.length} more to fill your box.`;
};
items.forEach((item) => item.addEventListener('click', () => {
if (picked.includes(item)) picked = picked.filter((other) => other !== item);
else if (picked.length < max) picked.push(item);
else { status.textContent = 'Your box is full. Remove an item first.'; return; }
paint();
}));
add.addEventListener('click', () => { addToCart(1); status.textContent = 'Monthly box added to your cart.'; });
paint();
});
document.querySelectorAll('[data-hover-list]').forEach((root) => {
const image = root.querySelector('[data-hover-image]');
root.querySelectorAll('[data-hover-row]').forEach((row) => {
const show = () => { image.src = row.dataset.image; image.alt = row.dataset.alt; image.style.opacity = '1'; };
row.addEventListener('mouseenter', show);
row.addEventListener('mousemove', (event) => { image.style.transform = `translate(${event.clientX + 24}px, ${event.clientY - 140}px) rotate(-3deg)`; });
row.addEventListener('mouseleave', () => { image.style.opacity = '0'; });
});
});
document.querySelectorAll('[data-rings]').forEach((root) => {
const rings = root.querySelectorAll('[data-ring]');
if (!watchMotion) return;
const full = 326.73;
rings.forEach((ring) => { ring.style.strokeDashoffset = full; });
const observer = new IntersectionObserver((entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return;
rings.forEach((ring) => { ring.style.strokeDashoffset = full * (1 - Number(ring.dataset.ring) / 100); });
observer.disconnect();
}, { threshold: 0.3 });
observer.observe(root);
});
document.querySelectorAll('[data-membership]').forEach((root) => {
root.addEventListener('change', (event) => {
const plan = event.target;
if (plan.name !== 'plan') return;
root.querySelector('[data-plan-label]').textContent = plan.dataset.label;
root.querySelector('[data-plan-note]').textContent = plan.dataset.note;
root.querySelector('[data-plan-price]').textContent = plan.dataset.price;
});
});
document.querySelectorAll('[data-catalog]').forEach((root) => {
const grid = root.querySelector('[data-catalog-grid]');
const items = [...grid.children];
const count = root.querySelector('[data-catalog-count]');
let kind = 'all';
const apply = () => {
const sort = root.querySelector('[data-catalog-sort]').value;
const sorted = [...items].sort((a, b) => sort === 'low' ? a.dataset.price - b.dataset.price : sort === 'high' ? b.dataset.price - a.dataset.price : a.dataset.order - b.dataset.order);
sorted.forEach((item) => { item.hidden = kind !== 'all' && item.dataset.kind !== kind; grid.append(item); });
count.textContent = sorted.filter((item) => !item.hidden).length;
};
root.querySelectorAll('[data-kind-tab]').forEach((tab) => tab.addEventListener('click', () => {
kind = tab.dataset.kindTab;
root.querySelectorAll('[data-kind-tab]').forEach((other) => { other.setAttribute('aria-selected', String(other === tab)); other.classList.toggle('active', other === tab); });
apply();
}));
root.querySelector('[data-catalog-sort]').addEventListener('change', apply);
apply();
});
document.querySelectorAll('[data-product]').forEach((root) => {
const main = root.querySelector('[data-product-main]');
root.querySelectorAll('[data-product-thumb]').forEach((thumb) => thumb.addEventListener('click', () => {
main.src = thumb.dataset.src; main.alt = thumb.dataset.alt;
root.querySelectorAll('[data-product-thumb]').forEach((other) => other.setAttribute('aria-pressed', String(other === thumb)));
}));
const qty = root.querySelector('[data-qty]');
const price = root.querySelector('[data-product-price]');
const button = root.querySelector('[data-add-to-cart]');
const paint = () => {
const option = [...root.querySelectorAll('[name="purchase"]')].find((radio) => radio.checked);
const total = Number(option.dataset.price) * Number(qty.value);
price.textContent = `$${total}`;
button.dataset.qty = qty.value;
};
root.querySelectorAll('[data-qty-step]').forEach((step) => step.addEventListener('click', () => { qty.value = Math.min(9, Math.max(1, Number(qty.value) + Number(step.dataset.qtyStep))); paint(); }));
root.addEventListener('change', paint);
paint();
});
document.querySelectorAll('[data-tabs]').forEach((tabs) => {
const buttons = [...tabs.querySelectorAll('[role="tab"]')];
const select = (tab) => {
buttons.forEach((other) => { const on = other === tab; other.setAttribute('aria-selected', String(on)); other.tabIndex = on ? 0 : -1; other.classList.toggle('active', on); document.getElementById(other.getAttribute('aria-controls')).hidden = !on; });
};
buttons.forEach((tab, index) => {
tab.addEventListener('click', () => select(tab));
tab.addEventListener('keydown', (event) => {
const move = { ArrowRight: 1, ArrowLeft: -1 }[event.key];
if (!move) return;
const next = buttons[(index + move + buttons.length) % buttons.length];
select(next); next.focus();
});
});
});
document.querySelectorAll('[data-cart]').forEach((root) => {
const threshold = Number(root.dataset.freeShipping);
const paint = () => {
const lines = [...root.querySelectorAll('[data-line]')];
let subtotal = 0; let units = 0;
lines.forEach((line) => { const qty = Number(line.querySelector('[data-line-qty]').value); units += qty; const total = qty * Number(line.dataset.price); subtotal += total; line.querySelector('[data-line-total]').textContent = `$${total}`; });
const discount = root.dataset.promo === 'on' ? Math.round(subtotal * 0.1) : 0;
const shipping = subtotal - discount >= threshold || subtotal === 0 ? 0 : 6;
root.querySelector('[data-subtotal]').textContent = `$${subtotal}`;
root.querySelector('[data-discount]').textContent = discount ? `−$${discount}` : '$0';
root.querySelector('[data-shipping]').textContent = shipping ? `$${shipping}` : 'Free';
root.querySelector('[data-total]').textContent = `$${subtotal - discount + shipping}`;
const left = Math.max(0, threshold - (subtotal - discount));
root.querySelector('[data-ship-progress]').style.width = `${Math.min(100, ((subtotal - discount) / threshold) * 100)}%`;
root.querySelector('[data-ship-note]').textContent = left ? `Add $${left} more for free shipping.` : 'You get free shipping.';
root.querySelector('[data-cart-empty]').hidden = lines.length > 0;
store.set('saltmere-cart', String(units)); paintCart();
};
root.addEventListener('click', (event) => {
const step = event.target.closest('[data-line-step]');
const remove = event.target.closest('[data-line-remove]');
if (step) { const input = step.closest('[data-line]').querySelector('[data-line-qty]'); input.value = Math.min(9, Math.max(1, Number(input.value) + Number(step.dataset.lineStep))); paint(); }
if (remove) { remove.closest('[data-line]').remove(); paint(); }
});
const promo = root.querySelector('[data-promo-form]');
promo.addEventListener('submit', (event) => {
event.preventDefault();
const code = promo.querySelector('[name="code"]').value.trim().toUpperCase();
const status = promo.querySelector('[data-form-status]');
if (code === 'SALT10') { root.dataset.promo = 'on'; status.textContent = 'SALT10 applied: 10% off.'; } else { status.textContent = 'That code does not work. Try SALT10.'; }
paint();
});
paint();
});
document.querySelectorAll('[data-slot-picker]').forEach((root) => {
const summary = root.querySelector('[data-slot-summary]');
root.addEventListener('change', () => {
const day = root.querySelector('[name="day"]:checked');
const time = root.querySelector('[name="time"]:checked');
summary.textContent = day && time ? `${day.dataset.label} at ${time.value}` : day ? `${day.dataset.label}, pick a time` : 'Pick a day and a time';
});
});
document.querySelectorAll('[data-faq-index]').forEach((index) => {
const links = [...index.querySelectorAll('[data-faq-link]')];
const mark = (id) => links.forEach((link) => { const on = link.getAttribute('href') === `#${id}`; link.classList.toggle('border-rust', on); link.classList.toggle('font-bold', on); link.classList.toggle('border-transparent', !on); });
links.forEach((link) => link.addEventListener('click', () => mark(link.getAttribute('href').slice(1))));
if (!('IntersectionObserver' in window)) return;
const observer = new IntersectionObserver((entries) => entries.forEach((entry) => { if (entry.isIntersecting) mark(entry.target.id); }), { rootMargin: '-30% 0px -60% 0px' });
document.querySelectorAll('[data-faq-item]').forEach((item) => observer.observe(item));
});
document.querySelectorAll('[data-contact-form]').forEach((form) => {
form.addEventListener('submit', (event) => {
event.preventDefault();
const status = form.querySelector('[data-form-status]');
if (!form.checkValidity()) {
status.textContent = form.dataset.error;
form.querySelector(':invalid')?.focus();
return;
}
status.textContent = form.dataset.success;
form.reset();
});
});
document.querySelectorAll('[data-timeline]').forEach((root) => {
const fill = root.querySelector('[data-timeline-fill]');
if (!watchMotion) return;
const update = () => {
const box = root.getBoundingClientRect();
const progress = Math.min(1, Math.max(0, (innerHeight * 0.6 - box.top) / box.height));
fill.style.transform = `scaleY(${progress})`;
};
update();
addEventListener('scroll', update, { passive: true });
});
document.querySelectorAll('[data-current-year]').forEach((el) => { el.textContent = new Date().getFullYear(); });
(() => {
const items = document.querySelectorAll('[data-reveal]');
if (!items.length || !watchMotion) return;
document.documentElement.classList.add('reveal-ready');
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
entry.target.classList.add('is-visible');
observer.unobserve(entry.target);
});
}, { rootMargin: '0px 0px -10% 0px' });
items.forEach((item) => observer.observe(item));
})();
</script>
</body>
</html>
E-commerce
Unlock the entire library of premium templates and blocks. Renews automatically, cancel anytime.