Examples

Examples

A cookbook of complete, working Plang section files: promo banner, testimonial slider, FAQ, product grid, pricing, mobile menu, order bar and a CSS restyle.

Every example on this page is a whole file you can paste into the Code tab and use. Each section file has three parts: the template (HTML and Plang), an optional {% style %} block and a {% schema %} that describes the settings owners edit. They were checked against the real engine, with no errors or warnings.

How to use one:

  1. In the Code tab create sections/<name>.plang (the file name is the section's handle: lowercase letters, digits and hyphens).
  2. Paste the file and save. Saving is refused if there is an error, with a line number.
  3. In the visual editor, add it from the section library, where it is listed under Your theme's sections, and fill in its settings.

Rules the schemas follow, which you should follow too:

  • A setting id starts with a lowercase letter, then letters, digits and _ (at most 40 characters): button_label, price2.
  • These ids are taken by the platform and rejected: bg, bgImage, overlay, pattern, edge, headingAlign, pad, anchor, handle, id, type, index. (Every section already gets background, spacing and anchor controls.)
  • Setting types: text, textarea, richtext, number, range, color, select, radio, checkbox, image, link, icon, collection, list. See settings.
  • "icon" is the name of an icon from the icon list (Percent, Info, ShoppingBag...), and "category" is one of hero, about, catalog, trust, media, contact, content or custom.
  • "padding": false removes the wrapper's top and bottom padding by default (good for strips and bars).
  • Class names in {% style %} are scoped to the section, so short names like .card can't clash with other sections.

Promo banner

A slim strip with a promo message and a WhatsApp button. It turns off the section padding, takes an optional colour, and the button comes from the platform button snippet, so a wa: link opens WhatsApp with the message ready.

plang
{%- assign s = section.settings -%}
{% shell padded: false %}
<div class="promo"{% if s.color != blank %} style="--promo: {{ s.color }}"{% endif %}>
	<div class="site-wrap promo-inner">
		<p class="promo-text">
			{%- if s.badge != blank %}<span class="promo-badge">{{ s.badge }}</span>{% endif %}
			{{ s.message }}
		</p>
		{% render 'button', label: s.button_label, link: s.button_link, size: 'sm' %}
	</div>
</div>

{% style %}
.promo { background: var(--promo, var(--c-brand)); color: #fff; }
.promo-inner { display: flex; flex-direction: column; align-items: center; gap: .75rem; padding-block: .875rem; text-align: center; }
.promo-text { font-weight: 600; }
.promo-badge { margin-right: .5rem; padding: .125rem .5rem; border-radius: 999px; background: #fff; color: #111; font-size: .75rem; font-weight: 800; text-transform: uppercase; }
@container (min-width: 48rem) {
	.promo-inner { flex-direction: row; justify-content: center; gap: 1.5rem; }
}
{% endstyle %}

{% schema %}
{
	"name": "Promo banner",
	"description": "A slim promo strip with a WhatsApp button",
	"icon": "Percent",
	"category": "content",
	"padding": false,
	"settings": [
		{ "type": "text", "id": "badge", "label": "Badge", "default": "Promo" },
		{ "type": "text", "id": "message", "label": "Message", "default": "Gratis ongkir se-Jabodetabek sampai Minggu ini" },
		{ "type": "color", "id": "color", "label": "Banner colour", "info": "Leave empty to use the brand colour." },
		{ "type": "text", "id": "button_label", "label": "Button label", "default": "Pesan lewat WhatsApp" },
		{ "type": "link", "id": "button_link", "label": "Button link", "default": "wa:Halo, saya mau ambil promo gratis ongkir" }
	]
}
{% endschema %}

The colour is only written to the page when the owner picks one ({% if s.color != blank %}), otherwise the CSS falls back to the brand colour. A color setting always holds a valid hex value or nothing, so it is safe to put in a style attribute.

Testimonial slider

Swipeable reviews with no JavaScript: a flex row with CSS scroll-snap. On phones a card takes most of the width so the next one peeks in; on wider websites two or three fit. Photos use the image snippet, which draws a placeholder when no photo is set, and the stars come from the stars snippet.

plang
{%- assign s = section.settings -%}
<div class="site-wrap">
	{% render 'heading', heading: s.heading, text: s.text, align: 'center' %}
	<div class="track" tabindex="0" role="region" aria-label="{{ s.heading | default: 'Testimoni' }}">
		{%- for r in s.reviews %}
		<figure class="slide site-card">
			{% render 'stars', rating: r.rating, class: 'size-5' %}
			<blockquote class="quote">“{{ r.quote }}”</blockquote>
			<figcaption class="who">
				{% render 'image', src: r.photo, alt: r.name, class: 'avatar' %}
				<span><strong>{{ r.name }}</strong>{% if r.role != blank %}<br><small>{{ r.role }}</small>{% endif %}</span>
			</figcaption>
		</figure>
		{%- endfor %}
	</div>
</div>

{% style %}
.track { display: flex; gap: 1rem; overflow-x: auto; scroll-snap-type: x mandatory; padding-bottom: 1rem; scrollbar-width: thin; }
.slide { flex: 0 0 85%; scroll-snap-align: start; background: var(--s-card); border-radius: var(--r); padding: 1.5rem; }
.quote { margin-top: 1rem; line-height: 1.6; }
.who { display: flex; align-items: center; gap: .75rem; margin-top: 1.25rem; }
.avatar { width: 2.75rem; height: 2.75rem; border-radius: 999px; }
.who small { color: var(--s-muted); }
@container (min-width: 48rem) {
	.slide { flex-basis: 46%; }
}
@container (min-width: 64rem) {
	.slide { flex-basis: 31%; }
}
{% endstyle %}

{% schema %}
{
	"name": "Testimonial slider",
	"description": "Swipeable customer reviews (CSS scroll-snap, no JavaScript)",
	"icon": "Quote",
	"category": "trust",
	"settings": [
		{ "type": "text", "id": "heading", "label": "Heading", "default": "Kata pelanggan kami" },
		{ "type": "textarea", "id": "text", "label": "Text", "rows": 2 },
		{
			"type": "list",
			"id": "reviews",
			"label": "Reviews",
			"item_label": "name",
			"max": 12,
			"add_label": "Add review",
			"fields": [
				{ "type": "textarea", "id": "quote", "label": "Quote", "default": "Kopinya enak, pengiriman cepat!" },
				{ "type": "text", "id": "name", "label": "Name", "default": "Sari" },
				{ "type": "text", "id": "role", "label": "Role or city", "default": "Bandung" },
				{ "type": "range", "id": "rating", "label": "Stars", "min": 1, "max": 5, "step": 1, "default": 5 },
				{ "type": "image", "id": "photo", "label": "Photo" }
			],
			"default": [
				{ "quote": "Kopinya enak, pengiriman cepat!", "name": "Sari", "role": "Bandung", "rating": 5 },
				{ "quote": "Harga bersahabat, kemasan rapi.", "name": "Budi", "role": "Surabaya", "rating": 5 },
				{ "quote": "Sudah langganan tiga bulan, tidak pernah kecewa.", "name": "Ani", "role": "Yogyakarta", "rating": 4 }
			]
		}
	]
}
{% endschema %}

The row has tabindex="0" and a role="region" label so keyboard users can scroll it with the arrow keys. The breakpoints use @container, not @media, so they react to the editor's phone and tablet previews.

FAQ accordion

Questions that open and close with the HTML <details> element. The answer is Markdown, written by the markdown filter. Two toggles control the behaviour: opening the first question, and closing the others when one opens (the name attribute groups the <details>, with section.id making the group unique per section).

plang
{%- assign s = section.settings -%}
<div class="site-wrap faq">
	{% render 'heading', heading: s.heading, align: 'center' %}
	<div class="faq-list">
		{%- for item in s.items %}
		<details class="faq-item"{% if s.one_at_a_time %} name="faq-{{ section.id }}"{% endif %}{% if forloop.first and s.open_first %} open{% endif %}>
			<summary>{{ item.question }}</summary>
			<div class="faq-answer">{{ item.answer | markdown }}</div>
		</details>
		{%- else %}
		<p class="faq-empty">{% if editing %}Tambahkan pertanyaan di pengaturan section ini.{% endif %}</p>
		{%- endfor %}
	</div>
</div>

{% style %}
.faq { max-width: 48rem; }
.faq-item { border-bottom: 1px solid var(--s-line); }
.faq-item summary { cursor: pointer; padding: 1rem 0; font-weight: 600; list-style: none; display: flex; justify-content: space-between; gap: 1rem; }
.faq-item summary::-webkit-details-marker { display: none; }
.faq-item summary::after { content: "+"; font-size: 1.25rem; line-height: 1; color: var(--s-hl); }
.faq-item[open] summary::after { content: "\2212"; }
.faq-answer { padding-bottom: 1rem; color: var(--s-muted); line-height: 1.7; }
{% endstyle %}

{% schema %}
{
	"name": "FAQ accordion",
	"description": "Questions that open and close with <details>",
	"icon": "Info",
	"category": "content",
	"settings": [
		{ "type": "text", "id": "heading", "label": "Heading", "default": "Pertanyaan yang sering ditanya" },
		{ "type": "checkbox", "id": "open_first", "label": "Open the first question", "default": true },
		{ "type": "checkbox", "id": "one_at_a_time", "label": "Close the others when one opens", "default": false },
		{
			"type": "list",
			"id": "items",
			"label": "Questions",
			"item_label": "question",
			"fields": [
				{ "type": "text", "id": "question", "label": "Question", "default": "Apakah bisa kirim ke luar kota?" },
				{ "type": "textarea", "id": "answer", "label": "Answer (Markdown)", "default": "Bisa. Kami kirim ke **seluruh Indonesia** lewat JNE dan SiCepat." }
			],
			"default": [
				{ "question": "Apakah bisa kirim ke luar kota?", "answer": "Bisa. Kami kirim ke **seluruh Indonesia** lewat JNE dan SiCepat." },
				{ "question": "Berapa lama pengiriman?", "answer": "Dalam kota 1 hari, luar kota 2 sampai 4 hari kerja." }
			]
		}
	]
}
{% endschema %}

The {% else %} of the for loop runs when the list is empty, and shows a hint only in the editor.

Product grid

Products from a data collection, with price, old price, badge and a WhatsApp order button for each. The collection setting lets the owner choose which collection to show. Read data for what each item contains.

plang
{%- assign s = section.settings -%}
{%- assign shown = s.products.items -%}
<div class="site-wrap">
	{% render 'heading', heading: s.heading, text: s.text, align: 'center' %}
	{%- if shown == blank %}
	<p class="empty">{% if editing %}Pilih koleksi data di pengaturan, lalu tambahkan produk di menu Data.{% endif %}</p>
	{%- else %}
	<ul class="cards">
		{%- for p in shown limit: s.max %}
		<li class="card site-card">
			<a href="{{ p.url | default: '#' }}" class="card-media">
				{% render 'image', src: p.image, alt: p.title, class: 'card-img' %}
				{%- if p.badge != blank %}<span class="card-badge">{{ p.badge }}</span>{% endif %}
			</a>
			<div class="card-body">
				<h3 class="card-title">{{ p.title }}</h3>
				{%- if p.subtitle != blank %}<p class="card-sub">{{ p.subtitle }}</p>{% endif %}
				<p class="card-price">
					<strong>{{ p.price }}</strong>
					{%- if p.old_price != blank %} <s>{{ p.old_price }}</s>{% endif %}
				</p>
				{%- assign order = 'wa:Halo, saya mau pesan ' | append: p.title -%}
				{% render 'button', label: 'Pesan', link: order, size: 'sm', class: 'w-full' %}
			</div>
		</li>
		{%- endfor %}
	</ul>
	{%- endif %}
</div>

{% style %}
.cards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; list-style: none; padding: 0; }
.card { background: var(--s-card); border-radius: var(--r); overflow: hidden; display: flex; flex-direction: column; }
.card-media { position: relative; display: block; }
.card-img { width: 100%; aspect-ratio: 1; }
.card-badge { position: absolute; left: .5rem; top: .5rem; padding: .125rem .5rem; border-radius: 999px; background: var(--s-hl); color: var(--s-hl-fg); font-size: .75rem; font-weight: 700; }
.card-body { display: flex; flex-direction: column; gap: .5rem; padding: 1rem; flex: 1; }
.card-title { font-weight: 600; }
.card-sub { font-size: .875rem; color: var(--s-muted); }
.card-price { margin-top: auto; }
.card-price strong { color: var(--s-hl); }
.card-price s { margin-left: .25rem; color: var(--s-muted); font-size: .875rem; }
@container (min-width: 48rem) {
	.cards { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1.5rem; }
}
@container (min-width: 64rem) {
	.cards { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
{% endstyle %}

{% schema %}
{
	"name": "Product grid",
	"description": "Products from a data collection, with a WhatsApp order button",
	"icon": "ShoppingBag",
	"category": "catalog",
	"settings": [
		{ "type": "text", "id": "heading", "label": "Heading", "default": "Produk kami" },
		{ "type": "textarea", "id": "text", "label": "Text", "rows": 2 },
		{ "type": "collection", "id": "products", "label": "Products", "info": "Choose the collection to list." },
		{ "type": "range", "id": "max", "label": "Show at most", "min": 2, "max": 24, "step": 1, "default": 8 }
	]
}
{% endschema %}

p.price and p.old_price are already formatted (Rp 25.000), so they are printed as they are. The order message is made with append and handed to the button snippet as a wa: link. p.url is nil when the collection has no detail pages, which the default: '#' covers.

Product detail

Put this section on a collection's detail page. It shows the photos, price, facts and description of the item shown (entry). In the editor it shows a hint if it is placed somewhere without an item.

plang
{%- if entry == nil -%}
{% if editing %}<p class="site-wrap">Section ini dipakai di halaman detail produk.</p>{% endif %}
{%- else -%}
<div class="site-wrap detail">
	<p class="crumbs"><a href="{{ entry.collection.url }}">{{ entry.collection.name }}</a> / {{ entry.title }}</p>
	<div class="detail-grid">
		<div class="gallery">
			{% render 'image', src: entry.image, alt: entry.title, class: 'main-photo', eager: true %}
			{%- if entry.images.size > 1 %}
			<div class="thumbs">
				{%- for src in entry.images limit: 5 %}
				{% render 'image', src: src, alt: entry.title, class: 'thumb' %}
				{%- endfor %}
			</div>
			{%- endif %}
		</div>
		<div class="info">
			{%- if entry.badge != blank %}<span class="badge">{{ entry.badge }}</span>{% endif %}
			<h1 class="site-heading">{{ entry.title }}</h1>
			<p class="price">
				<strong>{{ entry.price }}</strong>
				{%- if entry.old_price != blank %} <s>{{ entry.old_price }}</s>{% endif %}
			</p>
			{%- if entry.summary != blank %}<p class="summary">{{ entry.summary }}</p>{% endif %}
			{%- assign order = 'wa:Halo, saya mau pesan ' | append: entry.title | append: ' (' | append: entry.price | append: ')' -%}
			{% render 'button', label: 'Pesan lewat WhatsApp', link: order, size: 'lg' %}
			{%- if entry.facts.size > 0 %}
			<dl class="facts">
				{%- for fact in entry.facts %}
				<dt>{{ fact.label }}</dt>
				<dd>{% if fact.href %}<a href="{{ fact.href }}">{{ fact.value }}</a>{% else %}{{ fact.value }}{% endif %}</dd>
				{%- endfor %}
			</dl>
			{%- endif %}
		</div>
	</div>
	{%- if entry.body != blank %}
	<div class="body">{{ entry.body }}</div>
	{%- endif %}
</div>
{%- endif -%}

{% style %}
.crumbs { margin-bottom: 1.5rem; font-size: .875rem; color: var(--s-muted); }
.detail-grid { display: grid; gap: 2rem; }
.main-photo { width: 100%; aspect-ratio: 1; border-radius: var(--r); }
.thumbs { display: flex; gap: .5rem; margin-top: .5rem; }
.thumb { width: 4rem; height: 4rem; border-radius: calc(var(--r) / 2); }
.badge { display: inline-block; margin-bottom: .75rem; padding: .125rem .625rem; border-radius: 999px; background: var(--s-hl); color: var(--s-hl-fg); font-size: .75rem; font-weight: 700; }
.price { margin: .75rem 0; font-size: 1.5rem; }
.price strong { color: var(--s-hl); }
.price s { font-size: 1rem; color: var(--s-muted); }
.summary { margin-bottom: 1.5rem; color: var(--s-muted); line-height: 1.7; }
.facts { display: grid; grid-template-columns: auto 1fr; gap: .5rem 1rem; margin-top: 2rem; }
.facts dt { color: var(--s-muted); }
.body { margin-top: 3rem; max-width: 42rem; line-height: 1.75; }
@container (min-width: 48rem) {
	.detail-grid { grid-template-columns: 1fr 1fr; gap: 3rem; }
}
{% endstyle %}

{% schema %}
{
	"name": "Product detail",
	"description": "Photos, price, facts and description of the item on a detail page",
	"icon": "Package",
	"category": "catalog",
	"settings": []
}
{% endschema %}

entry.body is the one value that is written without escaping, because it is already safe HTML made from Markdown. entry.facts gives ready-made label and value pairs for every other field.

Pricing table

A list setting of plans: name, price (a number), a period, one feature per line, and a button. plan.price | money formats rupiah, lines splits the features, and one plan can be highlighted.

plang
{%- assign s = section.settings -%}
<div class="site-wrap">
	{% render 'heading', heading: s.heading, text: s.text, align: 'center' %}
	<div class="plans">
		{%- for plan in s.plans %}
		<div class="plan site-card{% if plan.featured %} plan-featured{% endif %}">
			{%- if plan.featured %}<span class="plan-flag">Paling laris</span>{% endif %}
			<h3 class="plan-name">{{ plan.name }}</h3>
			<p class="plan-price">
				{%- if plan.price > 0 %}<strong>{{ plan.price | money }}</strong> <small>{{ plan.period }}</small>{% else %}<strong>Gratis</strong>{% endif -%}
			</p>
			{%- assign features = plan.features | lines -%}
			<ul class="plan-features">
				{%- for line in features %}
				<li>{{ 'Check' | icon: class: 'plan-check' }}<span>{{ line }}</span></li>
				{%- endfor %}
			</ul>
			{% render 'button', label: plan.button_label, link: plan.button_link, variant: 'outline', class: 'w-full' %}
		</div>
		{%- endfor %}
	</div>
</div>

{% style %}
.plans { display: grid; gap: 1rem; }
.plan { position: relative; background: var(--s-card); border-radius: var(--r); padding: 1.75rem; display: flex; flex-direction: column; gap: 1rem; }
.plan-featured { outline: 2px solid var(--s-hl); }
.plan-flag { position: absolute; top: -.75rem; left: 1.75rem; padding: .125rem .75rem; border-radius: 999px; background: var(--s-hl); color: var(--s-hl-fg); font-size: .75rem; font-weight: 700; }
.plan-name { font-weight: 700; font-size: 1.125rem; }
.plan-price strong { font-size: 2rem; }
.plan-price small { color: var(--s-muted); }
.plan-features { display: grid; gap: .5rem; list-style: none; padding: 0; margin: 0 0 .5rem; flex: 1; }
.plan-features li { display: flex; gap: .5rem; }
.plan-check { width: 1.1rem; height: 1.1rem; margin-top: .2rem; flex-shrink: 0; color: var(--s-hl); }
@container (min-width: 48rem) {
	.plans { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1.5rem; }
}
{% endstyle %}

{% schema %}
{
	"name": "Pricing table",
	"description": "Up to four plans, one feature per line",
	"icon": "Tags",
	"category": "catalog",
	"settings": [
		{ "type": "text", "id": "heading", "label": "Heading", "default": "Pilih paket" },
		{ "type": "textarea", "id": "text", "label": "Text", "rows": 2 },
		{
			"type": "list",
			"id": "plans",
			"label": "Plans",
			"item_label": "name",
			"max": 4,
			"add_label": "Add plan",
			"fields": [
				{ "type": "text", "id": "name", "label": "Name", "default": "Paket" },
				{ "type": "number", "id": "price", "label": "Price (Rp)", "min": 0, "step": 1000, "default": 0 },
				{ "type": "text", "id": "period", "label": "Period", "default": "/ bulan" },
				{ "type": "textarea", "id": "features", "label": "Features (one per line)", "rows": 5 },
				{ "type": "checkbox", "id": "featured", "label": "Highlight this plan", "default": false },
				{ "type": "text", "id": "button_label", "label": "Button label", "default": "Pesan lewat WhatsApp" },
				{ "type": "link", "id": "button_link", "label": "Button link", "default": "wa:Halo, saya tertarik dengan paket ini" }
			],
			"default": [
				{ "name": "Hemat", "price": 150000, "period": "/ bulan", "features": "1 halaman\nSubdomain gratis", "button_label": "Pilih Hemat", "button_link": "wa:Halo, saya mau paket Hemat" },
				{ "name": "Usaha", "price": 350000, "period": "/ bulan", "features": "5 halaman\nDomain sendiri\nFormulir WhatsApp", "featured": true, "button_label": "Pilih Usaha", "button_link": "wa:Halo, saya mau paket Usaha" },
				{ "name": "Bisnis", "price": 750000, "period": "/ bulan", "features": "Halaman tanpa batas\nPrioritas bantuan", "button_label": "Pilih Bisnis", "button_link": "wa:Halo, saya mau paket Bisnis" }
			]
		}
	]
}
{% endschema %}

A for loop can't take a filter in its target, so the features are first put in features with assign. A price of 0 shows "Gratis".

Mobile menu with popover

A top bar whose phone menu opens with the HTML popover attribute: a <button popovertarget="..."> opens a <div popover="auto">, the browser closes it with the Escape key or a tap outside, and the cross button has popovertargetaction="hide". No JavaScript is involved. The menu's id uses section.id so two copies of the section don't share one.

plang
{%- assign s = section.settings -%}
{%- assign menu_id = 'menu-' | append: section.id -%}
{% shell padded: false %}
<nav class="bar site-wrap" aria-label="{{ 'mainMenu' | ui }}">
	<a class="brand site-heading" href="{{ 'page:' | link }}">{{ site.name }}</a>
	<ul class="links">
		{%- for item in s.links %}
		<li><a href="{{ item.link | default: '#' }}">{{ item.label }}</a></li>
		{%- endfor %}
	</ul>
	<button type="button" class="burger" popovertarget="{{ menu_id }}" aria-label="{{ 'openMenu' | ui }}">{{ 'Menu' | icon: class: 'burger-icon' }}</button>
	<div id="{{ menu_id }}" popover="auto" class="sheet">
		<button type="button" class="close" popovertarget="{{ menu_id }}" popovertargetaction="hide" aria-label="Tutup"><span aria-hidden="true">&times;</span></button>
		<ul class="sheet-links">
			{%- for item in s.links %}
			<li><a href="{{ item.link | default: '#' }}">{{ item.label }}</a></li>
			{%- endfor %}
		</ul>
		{% render 'button', label: s.cta_label, link: s.cta_link, class: 'w-full' %}
	</div>
</nav>

{% style %}
.bar { display: flex; align-items: center; justify-content: space-between; gap: 1.5rem; height: 4rem; }
.brand { font-size: 1.25rem; }
.links { display: none; gap: 1.5rem; list-style: none; margin: 0; padding: 0; }
.burger, .close { display: grid; place-items: center; width: 2.5rem; height: 2.5rem; border: 0; border-radius: var(--r-btn); background: transparent; color: var(--s-fg); }
.burger-icon { width: 1.5rem; height: 1.5rem; }
.close { font-size: 1.75rem; line-height: 1; }
.sheet { position: fixed; inset: 0 0 auto 0; width: auto; margin: 0; border: 0; padding: 1rem 1.25rem 1.5rem; background: var(--s-bg); color: var(--s-fg); box-shadow: 0 20px 40px -20px rgb(0 0 0 / .4); }
.sheet::backdrop { background: rgb(0 0 0 / .4); }
.close { margin-left: auto; }
.sheet-links { list-style: none; margin: 0 0 1rem; padding: 0; }
.sheet-links a { display: block; padding: .875rem 0; border-bottom: 1px solid var(--s-line); font-weight: 500; }
@container (min-width: 48rem) {
	.links { display: flex; }
	.burger { display: none; }
}
{% endstyle %}

{% schema %}
{
	"name": "Menu with popover",
	"description": "A top bar whose mobile menu opens with the HTML popover attribute",
	"icon": "Menu",
	"category": "custom",
	"settings": [
		{
			"type": "list",
			"id": "links",
			"label": "Menu links",
			"item_label": "label",
			"max": 8,
			"fields": [
				{ "type": "text", "id": "label", "label": "Label", "default": "Menu" },
				{ "type": "link", "id": "link", "label": "Link" }
			],
			"default": [
				{ "label": "Produk", "link": "#produk" },
				{ "label": "Harga", "link": "#harga" },
				{ "label": "Kontak", "link": "#kontak" }
			]
		},
		{ "type": "text", "id": "cta_label", "label": "Button label", "default": "Pesan lewat WhatsApp" },
		{ "type": "link", "id": "cta_link", "label": "Button link", "default": "wa:Halo, saya mau pesan" }
	]
}
{% endschema %}
Tip: popover and popover="auto" are the same. The cross could also be {{ 'X' | icon: class: 'size-5' }} (PascalCase X is the close icon; lowercase x is the X logo).

The bar uses {% shell padded: false %} to remove the section padding, and the links list setting feeds both the desktop row and the popover. {{ 'page:' | link }} is the address of the home page in the current language.

Sticky order bar

A bar fixed to the bottom of the screen on phones, with a title and a WhatsApp button; it hides itself on wider websites.

plang
{%- assign s = section.settings -%}
{% shell padded: false %}
<div class="order-bar">
	<div class="order-text">
		<strong>{{ s.title }}</strong>
		{%- if s.note != blank %}<span>{{ s.note }}</span>{% endif %}
	</div>
	{% render 'button', label: s.button_label, link: s.button_link, size: 'sm' %}
</div>

{% style %}
.order-bar { position: fixed; inset: auto 0 0 0; z-index: 30; display: flex; align-items: center; justify-content: space-between; gap: .75rem; padding: .75rem 1rem; background: var(--s-bg); color: var(--s-fg); border-top: 1px solid var(--s-line); box-shadow: 0 -8px 24px -12px rgb(0 0 0 / .3); }
.order-text { display: grid; line-height: 1.25; }
.order-text span { font-size: .8125rem; color: var(--s-muted); }
@container (min-width: 48rem) {
	.order-bar { display: none; }
}
{% endstyle %}

{% schema %}
{
	"name": "Sticky order bar",
	"description": "A bar fixed to the bottom of phone screens",
	"icon": "MessageCircle",
	"category": "contact",
	"padding": false,
	"settings": [
		{ "type": "text", "id": "title", "label": "Title", "default": "Mau pesan sekarang?" },
		{ "type": "text", "id": "note", "label": "Small note", "default": "Dibalas dalam 5 menit" },
		{ "type": "text", "id": "button_label", "label": "Button label", "default": "Pesan lewat WhatsApp" },
		{ "type": "link", "id": "button_link", "label": "Button link", "default": "wa:Halo, saya mau pesan" }
	]
}
{% endschema %}

Add some bottom padding to the footer (or the last section) so the bar doesn't cover content.

Restyling built-in sections

You don't have to replace a built-in section to change how it looks. A CSS file in assets/ is loaded on every page, and each built-in section has data-section="<type>" on its wrapper. This example also adds two theme settings, which the CSS reads through --ts-* variables.

config/settings_schema.json:

json
[
	{
		"name": "Accent",
		"settings": [
			{ "type": "color", "id": "accent_color", "label": "Accent colour", "default": "#D62828" },
			{ "type": "range", "id": "card_lift", "label": "Card lift on hover", "min": 0, "max": 12, "step": 2, "unit": "px", "default": 4 }
		]
	}
]

assets/theme.css:

css
/* Loaded on every page, scoped to .site-root. */

/* Tighter hero headline */
[data-section="hero"] h1 {
	letter-spacing: -0.03em;
}

/* Feature cards lift on hover (--ts-card-lift comes from the "Card lift on hover" theme setting) */
[data-section="features"] .site-card {
	transition: transform 0.2s ease;
}
[data-section="features"] .site-card:hover {
	transform: translateY(calc(var(--ts-card-lift, 4px) * -1));
}

/* Pricing cards get the accent colour as a top border */
[data-section="pricing"] .site-card {
	border-top: 4px solid var(--ts-accent-color, var(--c-brand));
}

/* A fade-in for the testimonials section (the keyframes name becomes t-rise automatically) */
@keyframes rise {
	from { opacity: 0; transform: translateY(12px); }
	to { opacity: 1; transform: none; }
}
[data-section="testimonials"] .site-card {
	animation: rise 0.5s ease both;
}

@container (min-width: 48rem) {
	[data-section="hero"] h1 {
		letter-spacing: -0.04em;
	}
}

The saved CSS is scoped to the website (every rule starts with .site-root), the rise keyframes are renamed t-rise together with their use, and a @container query works like in a section. Because theme CSS isn't in a cascade layer it normally wins over the platform's utility classes. For the rules, see styling.

Where next

  • Language for every tag, operator and limit used above.
  • Filters and objects for everything the templates read.
  • Snippets to move repeated markup into its own file.
  • Security for what is allowed in the markup.