pages_list() (and the block editor's Pages List block) accept a search expression:
| Value | Expected behavior |
|---|---|
children | filter children pages (case insensitive) |
grandchildren | filter grandchildren pages’s pages (case insensitive) |
children_children | alias for grandchildren (deprecated) |
sisters | filter sister pages (case insensitive) |
parent_children | alias for sisters (deprecated) |
related | if there is a parent page, filter results with sister pages with a pageId inferior to currentPageId + 3 |
comment:exampleValue | filter pages containing in mainContent <!--exampleValue--> (deprecated, prefer tags) |
related:comment:exampleValue | same as related but instead of sister pages, it’s pages containing the comment |
title:exampleValue | filter pages containing in title (seo title or h1) the exampleValue |
content:exampleValue | same than title + searching in mainContent too |
slug:exampleValue | filter pages with the exact slug (useful only with OR); page: is an alias |
slug:%exampleValue% | same than slug: with % ➜ filter page with the slug containing exampleValue. |
template:exampleValue | filter pages rendered by this template |
parent:exampleSlug | filter pages whose parent page has this slug |
ancestor:exampleSlug | filter pages sitting under that page, at any depth — a whole section in one condition |
locale:exampleValue | filter pages of that language (a list is already filtered on the current one) |
tag:exampleValue | filter tag — the explicit form of writing the tag on its own |
prop:key:value | filter pages where custom property key equals value |
customProperty:key:value | the older spelling of prop: |
| exampleValue | filter tag (exact match only!) |
Anything else is a tag search — type:product is a valid tag name, so a mistyped prefix silently matches nothing. pw:pages-list:lint reports the searches on your site that match no page.
OR or ANDBoth may appear in one query, but mixing them requires parentheses — there is no precedence rule, so an ungrouped mix is refused.
Examples:
related:comment:blog OR relatedparent_children OR related OR page:custom-slugparent_children AND related AND page:custom-slug (this one will output only 1 result)(parent_children AND related) OR page:custom-slugtag:blog AND (tag:featured OR tag:pinned)parent_children AND related OR page:custom-slug ➜ ambiguous, group one sideAND and OR are operators only as whole uppercase words (ORANGE or or stay text). A ( opens a group only where a term may start — at the beginning, after an operator or after another ( — so a tag written foo (bar) is still a tag.
The third argument sorts the list: any page column, with an optional direction ('weight DESC, publishedAt DESC'; ↑ and ↓ are accepted), or prop.<key> for a custom property. It defaults to publishedAt,weight.
order: 'search' keeps the pages in the order their slug: or prop: terms are written — for a hand-picked row of cards:
{{ pages_list('slug:tour-du-mont-blanc OR slug:gr54 OR slug:vercors', order: 'search', view: 'card') }}
{{ pages_list('prop:tripCode:TMB11 OR prop:tripCode:GR54', order: 'search', view: 'card') }}
prop: term survives a renamed slug. Pages sharing its value share its position, and what follows search orders them among themselves.slug:tour-du-mont-blanc OR tag:trek therefore pins one card at the head of a tag list.search orders that tail: order: 'search, weight ↓, publishedAt ↓'. Alone, search leaves the default order underneath. search must open the expression — it sorts the head, so it cannot come second.slug:%partial% matches more than one page, so it holds no single position. A search naming no exact slug or property simply gets the default order.max cuts after the reordering, so it keeps the ones written first, not the most recent ones.pages() takes it too, for a template that arranges the entities itself:
{% set items = pages(where: 'slug:tour-du-mont-blanc OR slug:gr54', order: 'search') %}
The block editor’s order select offers both forms and keeps a hand-written order as is.
The fourth argument picks the view. Three are built in; any other bare name is a display variant your site provides by convention, and a value containing / or . is taken as a template path.
| Value | Renders |
|---|---|
list (or empty) | a plain <ul> of links — component/pages_list.html.twig |
card | the card grid — component/pages_list_card.html.twig |
horizontalScroll | the same cards in one scrolling row — component/pages_list_horizontal.html.twig |
| any other bare name | your site’s component/pages_list_<name>.html.twig |
{{ pages_list('type:blog', 9, 'publishedAt ↓', 'horizontalScroll') }}
All three are available from the block editor’s format select.
A template at templates/<host>/component/pages_list_smallCard.html.twig (or any other override location) makes smallCard a valid view name, in Twig and in the block editor. It receives the built-in views' variables: pages, pager, pager_route, pager_route_params, id, wrapperClass.
To offer the variant in the block editor’s format select, declare it on the app:
pushword:
apps:
- hosts: [example.tld]
pages_list_displays: [smallCard]
A block saved with an undeclared variant keeps working; the select shows the stored name.
The card view’s columns live on the wrapper alone (grid gap-2 sm:grid-cols-2 md:grid-cols-3), so wrapperClass — or the block’s class tune — replaces the layout without a template override:
{{ pages_list('type:blog', 8, 'publishedAt ↓', 'card', wrapperClass: 'not-prose grid gap-4 sm:grid-cols-2 lg:grid-cols-4 my-5') }}
Classes typed in content only work if they exist in your compiled CSS: safelist the grid classes you allow, or reuse classes your templates already contain.
horizontalScroll renders the card view’s cards inside .horizontal-scroll, with no JavaScript: the prev/next arrows are ::scroll-button() pseudo-elements, the edge fade is a mask-image, and disabled arrows come from :disabled.
An edge fades only when content is scrolled past it (a scroll-driven animation guarded by @supports; unsupported browsers fade both edges). Add horizontal-scroll-dots to wrapperClass for position dots — visible cards filled, half-visible half-filled. A row whose cards all fit draws no dots, fade or arrows.
The arrows are Chromium-only; elsewhere the row scrolls by trackpad, swipe, shift+wheel or scrollbar. Keep the scroller overflow-x: auto, never hidden, or browsers without arrows cannot reach the content. For the same reason the scrollbar is hidden only where ::scroll-button() is supported:
@supports selector(::scroll-button(inline-end)) {
.horizontal-scroll { scrollbar-width: none; }
}
Limits:
aria-label. The template sets --horizontal-scroll-previous and --horizontal-scroll-next from the horizontalScrollPrevious / horizontalScrollNext translation keys; override them in your own CSS or translations, not in the markup.wrapperClass lands on the wrapper (the arrows' positioning box), not on the scrolling row. Inside a prose column the scroller inherits its 65ch width; pass bleed to break out of it, with a gutter before the first card:
{{ pages_list('type:blog', 9, 'publishedAt ↓', 'horizontalScroll', wrapperClass: 'bleed') }}
With the block editor, write the call positionally and fully quoted (wrapperClass is the sixth argument). A named argument or a bare 9 still renders, but the editor keeps the call as a raw block instead of a Pages List block.
{{ pages_list('type:blog', '9', 'publishedAt ↓', 'horizontalScroll', '0', 'bleed') }}
Custom properties:
| Property | Default | Effect |
|---|---|---|
--horizontal-scroll-fade | 2rem | width of the edge fade |
--horizontal-scroll-gutter | 0, 1rem under bleed | space before the first card |
--horizontal-scroll-thumb | #d1d5db | scrollbar thumb, where it is shown |
--horizontal-scroll-previous / --horizontal-scroll-next | from translations | the arrows' accessible names |
Set --horizontal-scroll-thumb on a dark background. The explicit colour stops Firefox from drawing an overlay scrollbar that only appears while scrolling.
excludeAlreadyLinked: true drops pages the content already links to:
{{ pages_list('taxonomy:travel', 6, excludeAlreadyLinked: true) }}
It also skips pages an earlier list on the same page rendered, so a hub with several lists shows each page once. With pages(), use exclude_linked():
{% set uniquePages = exclude_linked(pages(host, 'taxonomy:travel')) %}
Details: Link Collector.
max alone caps a list. Pass maxPages as well and the same list is paginated: max becomes the number of cards on one pager page, and maxPages the number of pager pages the list may ever have.
{{ pages_list('type:blog', 12, maxPages: 5) }}
12 cards per page across at most 5 pages — 60 posts, newest first. The pager is component/pager.html.twig (extends Pagerfanta’s Tailwind view); override it to restyle.
max is required as soon as maxPages > 1 ("max" (items per page) must be >= 1 when paginating with maxPages).maxPages is a ceiling: one query fetches max × maxPages rows and Pagerfanta slices them. Rows beyond that product are unreachable.maxPages: 1 (or 0) disables pagination: no pager is rendered.The legacy array form pages_list('type:blog', [12, 5]) still works; combining it with maxPages is an error.
The pager appends the page number as a path segment on the current page:
/blog ← page 1
/blog/2 ← page 2
Page 1 is the bare URL. The route is built from the current page, so pagination works on any page and host without configuration. Caveats:
excludeAlreadyLinked only sees the current pager page. The other pages are never rendered, so they cannot register their cards with the Link Collector./2, /3… at the site root, which collide with any page whose slug is a bare number, and the page wins — the pager silently shows page 1 again. Paginate a section page instead.pages_list() only returns pages online now. draft_list() takes the same arguments and views over the complement: pages with no publishedAt or scheduled for later — as full entities (cards with title and image), unlike the bare slugs of page_uri_list().
{{ draft_list('type:blog', 999, 'publishedAt DESC', wrapperClass: 'bg-pink-50 p-4') }}
It renders nothing unless a ROLE_EDITOR is logged in, so it can live on a public page; the Markdown cache keys on post-Twig text, so editor and visitor renders never share an entry. pw:static renders as a visitor, so static HTML gets nothing.
Differences from pages_list():
pages_list() drops them) — a draft list that hid them would hide exactly the pages you are looking for;publishedAt puts never-scheduled pages last, since their publishedAt is NULL.Redirections are excluded, as in pages_list().
Before parsing the search string, Pushword dispatches a PagesListSearchEvent. A listener can rewrite it — e.g. to expand an app-specific prefix into standard ones.
Event: Pushword\Core\Event\PagesListSearchEvent
Constant: PushwordEvents::PAGES_LIST_SEARCH (pushword.pages_list.before_search)
Dispatched by: pages_list() and pages() Twig functions
use Pushword\Core\Event\PagesListSearchEvent;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
#[AsEventListener(event: PagesListSearchEvent::NAME)]
final readonly class ProductSearchListener
{
public function __invoke(PagesListSearchEvent $event): void
{
// The raw search, before parsing — so a rewrite must stop at the
// delimiters. A `\S+` here would swallow a closing parenthesis and turn
// "(product:A OR product:B)" into a term ending in ")".
$event->setSearch(preg_replace(
'/product:([^\s)]+)/',
'prop:productCode:$1',
$event->getSearch(),
) ?? $event->getSearch());
}
}
setSearch() replaces the string; getCurrentPage() gives the current page.