Product list blocks
Two ways to lay out products in a store: a full category page with a sort and
view toolbar, and a featured product beside a small grid. Each block is one
.svelte file built from SvButton, with SvDropDownList and SvSegmented
in the toolbar and SvBadge on the featured tile. Prices are integer cents
formatted with Intl.NumberFormat, and the product pictures are inline SVG
tinted from the --sg-* tokens.
Installation
Add the whole group, or one block by its name (shown under each demo):
See them first with npx @svgrid/ui try blocks/product-lists. No project needed.
Product list with toolbar
Twelve products with a rating, review count and price under a toolbar: the
result count, an SvDropDownList sort (featured, price low to high, price high
to low, top rated, newest) and an SvSegmented switch between a card grid and
a list with a one-line description. The first eight show; Load more waits for
onLoadMore and shows the next page, then moves focus to the first new
product. A new sort goes back to page 1. Below 640 px the grid has two columns
and the sort gets its own row.
npx @svgrid/ui add product-lists-toolbar
Featured product list
One large tile for the featured product (picture, name, price, a short pitch
and Shop now) beside a 2x2 grid of smaller products. Each small tile has an Add
button that shows its loading state while onAdd runs, and every tile has a
heart that toggles the product on the saved list. Below 900 px the featured
tile moves above the grid.
npx @svgrid/ui add product-lists-featured
Sorting and paging on the server
In the demo, createProductListToolbar() sorts and pages the twelve products
in the browser. A real catalog is too big for that, so let the server do both
and keep the state in the URL: ?sort=price-asc&page=2 is then a link someone
can share, and a reload shows the same products.
The load function reads both params, checks them, and returns the first
page * 8 rows. Returning every page up to the current one (rather than only
page 2) matches the Load more pattern: a reload of ?page=3 shows all 24
products, not just the last 8. listProducts stands in for your database
call. Map sort to an ORDER BY from a fixed table and pass the limit as a
bound parameter; never put either param into the SQL string.
// src/routes/shop/+page.server.ts
import type { PageServerLoad } from './$types'
import { listProducts } from '$lib/server/products'
const SORTS = ['featured', 'price-asc', 'price-desc', 'rating', 'newest'] as const
const PAGE_SIZE = 8
export const load: PageServerLoad = async ({ url }) => {
const sort = SORTS.find((s) => s === url.searchParams.get('sort')) ?? 'featured'
const page = Math.min(50, Math.max(1, Math.floor(Number(url.searchParams.get('page'))) || 1))
// Returns { items, total }: the first `limit` rows in `sort` order, and the full count.
const { items, total } = await listProducts({ sort, limit: page * PAGE_SIZE })
return { items, total, sort, page }
}
Then give the block a data prop and hand the rows to its core as getters.
When total is set, the core stops sorting and slicing: it shows products as
they come and offers Load more while fewer than total are on screen. Sort
and Load more become navigations, and the core waits for them, so the button
keeps its loading state (and the list dims during a new sort) until the new
rows arrive. In the block's instance script:
import { untrack } from 'svelte'
import { goto } from '$app/navigation'
import { page } from '$app/state'
let { data }: { data: { items: ListProduct[]; total: number; sort: ProductSort; page: number } } = $props()
function go(params: Record<string, string>) {
const url = new URL(page.url)
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v)
return goto(url, { noScroll: true, keepFocus: true, replaceState: true })
}
const list = createProductListToolbar({
products: () => data.items,
total: () => data.total,
// Read once: after this the core owns sort and page, and the URL follows it.
initial: untrack(() => ({ sort: data.sort, page: data.page })),
onSortChange: (sort) => go({ sort, page: '1' }),
onLoadMore: (next) => go({ page: String(next) }),
onAdd: (p) => toast.success(`Added ${p.name} to your cart`),
})
ListProduct and ProductSort are exported from the block's own
<script module>, so they need no import. The route passes its data through:
<!-- src/routes/shop/+page.svelte -->
<script lang="ts">
import ProductList from '$lib/components/ui/product-lists-toolbar.svelte'
let { data } = $props()
</script>
<ProductList {data} />
The featured block needs no paging. Load its products in the same way, pass
them as createFeaturedProducts({ featured, products }), and point onShop
at the product page (onShop: (p) => goto('/shop/' + p.id)) and onAdd at
your cart endpoint. A throw from either goes to onError(error, product).
Headless
Both blocks are a styled render of the core in their <script module>:
createProductListToolbar and createFeaturedProducts. To draw your own
markup, take a core alone with npx @svgrid/ui add product-lists-toolbar --headless (it writes product-lists-toolbar.svelte.ts) and spread its
prop-getters on plain elements:
<script lang="ts">
import { createProductListToolbar } from './product-lists-toolbar.svelte.js'
const list = createProductListToolbar({ pageSize: 6 })
</script>
<select {...list.sortProps()}>
{#each list.sortOptions as o (o.value)}<option value={o.value}>{o.label}</option>{/each}
</select>
<button {...list.viewProps('grid')}>Grid</button>
<button {...list.viewProps('list')}>List</button>
<p>Showing {list.visible.length} of {list.total}</p>
<ul {...list.listProps()}>
{#each list.visible as p (p.id)}
<li>
{p.name}, {list.format(p.priceCents)}
<button {...list.addProps(p)}>Add to cart</button>
</li>
{/each}
</ul>
{#if list.hasMore}<button {...list.loadMoreProps()}>Load more</button>{/if}
viewProps sets aria-pressed on the active view, and listProps sets
aria-busy while a page or a server sort is loading and data-view to the
current view, so a stylesheet can switch the layout from [data-view='list'].
See also
Live examples
- Product list with toolbar block - Twelve store products under a toolbar with the result count, an SvDropDownList sort and an SvSegmented grid/list switch. Load more shows the next eight, and Add to cart fires a toast.
- Featured product list block - A large featured product tile with an SvBadge, price, a short pitch and a Shop now SvButton beside a 2x2 grid of smaller products, each with an Add button that loads and a save toggle.
Related articles
- Building an E-commerce Product Catalog Grid in Svelte - How to build a product catalog admin grid with image cells, inline price and stock edits, category filtering, and bulk updates using SvGrid.
- What's New in SvGrid Enterprise - Export, Pivot, Import, Print, and AI - A practical look at @svgrid/enterprise - pivot tables, Excel and PDF export, data import, printing, and an AI assistant. Covers when each feature earns its place and what to watch out for in production.