Theming
This page is for Twig and CSS themers. It covers the --kahunacart-* custom properties, the classes the shipped stylesheet targets, and how to choose between a variable, your own CSS, and a template override.
For the routes, templates and Twig functions behind the markup, see Storefront.
To change a page's markup without copying the whole template, see Theme blocks.
What ships
KahunaCart enqueues these files, on its own storefront routes and nowhere else:
| File | What it does |
|---|---|
assets/kahunacart.css |
One plain CSS file. No Tailwind, no preprocessor, no external fonts, no images. |
assets/kahunacart-options.js |
The product-option picker. |
assets/kahunacart-checkout.js |
The saved-address picker and the state field at checkout. |
assets/kahunacart-filters.js |
Applies the catalog filters as they are ticked. Enqueued only on a page that actually drew a filter panel. |
No script uses a framework, a build step or a dependency. Only the filter script talks to the network, and every request it makes is a GET of the URL the filter form would have navigated to. All files are cache-busted by file mtime, so a plugin update takes effect without a hard refresh. Webhook and download responses never receive any of them.
Every rule is scoped to .kahunacart, the wrapper class the shipped templates carry, so the stylesheet cannot repaint the rest of your site. Component rules wrap their outer selector in :where(), which adds no specificity, so a plain class selector in your own CSS beats most of what the plugin sets. Nothing in the file uses !important.
The stylesheet also handles :focus-visible rings on every control, tabular figures on money columns, prefers-reduced-motion, and cart tables that scroll inside themselves on a phone.
Note
The storefront is server-rendered HTML and works with scripting off, the option picker included. See The buy button and the option picker.
How token resolution works
The stylesheet uses internal --kc-* tokens. You never set those. Each token takes the first value that is defined, down this chain:
--kahunacart-* your override, site or theme
↓
--grav-* the theme's own design tokens, if it publishes them
↓
--theme-accent a generic accent hook, for the two accent tokens only
↓
neutral fallback derived from --kc-fg, or an absolute colour
If your theme already defines --grav-fg, --grav-border, --grav-bg-elevated and --grav-radius-md, the storefront picks them up and you write no CSS at all.
Borders, muted text and faint fills are all derived from --kahunacart-fg with color-mix(): borders at 13% and 24%, muted text at 68%, subtle text at 60%, faint fills at 3.5%. Changing --kahunacart-fg updates all of these neutral colors.
Tokens are declared on both :root and .kahunacart, so either selector works. Use .kahunacart to restyle the storefront without touching anything else on the site.
Custom property reference
The table lists each property, what it controls, and its default value.
| Property | Paints | Falls back to |
|---|---|---|
--kahunacart-fg |
Text, and the anchor the neutral ramp is derived from | --grav-fg → CanvasText |
--kahunacart-bg |
The page ground; used where a control must be opaque, such as a <select> option list |
--grav-bg → Canvas |
--kahunacart-border |
Card, table, fieldset and chip borders | --grav-border → 13% of fg |
--kahunacart-border-strong |
Input borders, the rule above the order total, button outlines | --grav-border-strong → 24% of fg |
--kahunacart-muted |
Secondary text: product summaries, field labels, totals labels | --grav-fg-muted → 68% of fg |
--kahunacart-subtle |
Tertiary text: table column heads, the cart remove control at rest | --grav-fg-subtle → 60% of fg |
--kahunacart-accent |
Ink and borders: hover states, the outline of a chosen shipping or payment row | --grav-purple-bright → --theme-accent → light-dark(#0b57d0, #7cacf8) |
--kahunacart-accent-strong |
The only token that fills a box behind a label: active category chip, Checkout and Place order buttons | --grav-purple → --theme-accent → light-dark(#0b57d0, #2563eb) |
--kahunacart-accent-ink |
Prices and product-title hover, where the accent is text on the page ground | --grav-purple → --kahunacart-accent |
--kahunacart-on-accent-strong |
The label colour on an accent-filled box | Computed from the fill's luma, black or white; #fff on older browsers |
--kahunacart-surface |
Faint panels: flash messages, download rows, the order meta block | --grav-bg-elevated → 3.5% of fg |
--kahunacart-surface-2 |
The deeper panel, used for the add-to-cart block | --grav-bg-subtle → --kahunacart-surface |
--kahunacart-card |
Product card fill; transparent by default so a patterned page background shows through | --grav-bg-elevated → transparent |
--kahunacart-card-ratio |
The aspect ratio of a product card's image | 4 / 3 |
--kahunacart-section-columns |
Columns in one block of a sectioned listing. Written onto each block from its Columns setting; set it yourself only when your theme renders sections from kahunacart_sections() |
3 |
--kahunacart-input |
Form control fill | --grav-bg-subtle → transparent |
--kahunacart-swatch |
The fill of a colour swatch that has no colour stored on it | 12% of fg |
--kahunacart-radius |
Buttons, inputs, small panels | --grav-radius-md → 8px |
--kahunacart-radius-lg |
Cards, fieldsets, the buy block | --grav-radius-lg → 12px |
--kahunacart-font-mono |
Coupon codes and category counts | --grav-font-mono → ui-monospace, SFMono-Regular, Menlo, monospace |
--kahunacart-font |
The storefront's text | the page's own font |
--kahunacart-font-display |
Headings inside the storefront, at no specificity, so a theme's own heading rule still wins | the storefront's text font |
--kahunacart-shadow |
The shadow under product, hero, subcategory and address cards | none |
--kahunacart-gap |
The gap between cards in a product grid and a hero row | 1.25rem |
--kahunacart-focus |
The keyboard focus ring | --kahunacart-accent-strong |
--kahunacart-ease |
Every transition's easing | --grav-ease → ease |
--kahunacart-duration |
Every transition's duration | --grav-duration-2 → 160ms |
--kahunacart-success |
Applied coupon, discount lines on the receipt | --grav-success → #1d9d61 |
--kahunacart-warning |
Offline payment instructions on the confirmation page | --grav-warning → #b8860b |
--kahunacart-danger |
Error messages, hovering the cart's remove control, the download denial notice | --grav-danger → #c0392b |
Override the storefront colours
Six properties usually cover the whole job.
-
Open your theme's CSS file.
-
Add the properties you want to change:
CSS:root { --kahunacart-accent: #2f7d6f; /* hover ink, chosen-option outlines */ --kahunacart-accent-strong: #1d5e53; /* filled: Checkout, Place order, active chip */ --kahunacart-accent-ink: #1d5e53; /* prices */ --kahunacart-radius: 4px; --kahunacart-radius-lg: 6px; --kahunacart-card: #fff; /* product cards get a real fill */ } -
Reload a storefront page.
The storefront now renders in your colours and your corner geometry. Everything you did not name still follows the page.
Support your theme's dark mode
Redefine only what changes.
-
Add a block under your theme's dark-mode selector:
CSShtml[data-theme="dark"] { --kahunacart-fg: #e6e8ee; --kahunacart-bg: #14161c; --kahunacart-card: #1b1e26; --kahunacart-accent: #7cc9b8; } -
Reload a storefront page in dark mode.
The neutral ramp follows the new --kahunacart-fg. You do not have to restate borders, muted text or faint fills.
Tip
If your theme flips dark mode without setting color-scheme, the stylesheet detects the common conventions (html[data-theme="dark"], html[data-bs-theme="dark"], html.dark, body[data-theme="dark"], body.dark) and sets color-scheme on the storefront. This only matters if you set no tokens of your own.
Turn off the built-in stylesheet
- Set
builtin_csstofalse, or use the matching toggle on the General tab of the plugin's admin settings. - Reload a storefront page.
No stylesheet is enqueued and the kahunacart-* classes are yours to style from scratch. The option picker's script stays on, because a theme still needs it to resolve a variant.
Important
Turn the stylesheet off only when your theme wants total control of storefront styling, or when you have replaced every shipped template. Otherwise leave it on and override what you need, so you keep the focus states, tabular figures, reduced-motion handling and small-screen table behaviour.
The buy button and the option picker
partials/kahunacart-buy-button.html.twig is the partial themes include most. It always renders both of these:
- The option widgets, when the product defines axes. Each axis gets a dropdown, a row of button pills, or colour swatches, plus a
<script type="application/json">carrying the picker's data. They shiphidden. - The plain
<select name="variant_id">, which is the field the form posts.
assets/kahunacart-options.js reveals the widgets, visually hides the select, writes the resolved variant into it, and fires a change event on it. So:
- The form works with JavaScript off. A browser that never loads the script posts exactly what it always did.
- The POST contract is unchanged:
variant_idandqty. - A theme listening for
changeon the select keeps working, whichever control the customer used.
The template variables are product, variants, options and options_payload. kahunacart_product('slug') sets all four.
Important
If you build the variable yourself, pass options (the presented axes) and options_payload (the encoded JSON). Without them the widgets do not render and the plain select stands.
Colour swatches read their colour from an inline --kc-swatch custom property, set on .kahunacart-option-choice and painted by .kahunacart-option-dot. Restyle a swatch with a normal CSS rule.
The picker keeps a few marked elements in step with the resolved variant. Inside the form it swaps the text of [data-kc-price] and [data-kc-compare] (the struck-through number, hidden when there is nothing to strike) and enables or disables [data-kc-submit] with the variant's stock. Anything on the page marked data-kc-sku, inside the form or not, gets the resolved variant's SKU written into it. Its dd (and the dt before it) is hidden when that variant has no SKU. The shipped Kahuna theme uses this for the SKU row in the product facts.
A sold-out product with one variant gets a button that says why. When the only variant is tracked, at zero and refusing backorders, the submit button is drawn disabled and greyed with kahunacart_stock()'s label in place of "Add to cart", and the quantity box is left out. The label is "Out of stock" for something that ships, and the store's own pick for something that does not. The button carries kahunacart-sold-out plus kahunacart-sold-out--out-of-stock, --not-available, --coming-soon or --custom. A product with a picker or a variant select keeps its normal button, because the shopper can switch to a variant that is in stock.
A product on backorder keeps its button and says why. When the only variant is tracked, at zero and allowing backorders, the button still says "Add to cart" and a .kahunacart-backorder-note paragraph after it prints kahunacart_stock()'s label: the variant's Backorder note, the store's wording, or "On backorder". With a picker, the status line ([data-kc-status]) shows the same words and gets is-backorder while the chosen variant is on backorder. Cart, checkout and receipt lines print theirs in .kahunacart-line-backorder. All three take their colour from --kc-warning. The Kahuna theme (1.0.7) shows the words in its own stock line on the product page and hides the plugin's note beside the button so it is not said twice. See Telling the shopper it is on backorder.
The price can say whether it includes tax. With tax.price_note on, the partial prints a .kahunacart-tax-note line ("incl. VAT") after the price and sale badge, through partials/kahunacart-tax-note.html.twig. The product grid and hero section cards print the same line. A theme that prints its own lead price and its own note beside it passes show_tax_note: false to the buy button so the page does not say it twice, which is what the Kahuna theme (1.0.8) does. See Storefront → Saying whether a price includes tax.
A pay-what-you-want product gets a price box. The partial draws a Name your price box inside the form, posting line_data[pay_amount], and the picker keeps its minimum in step with the resolved variant. See Storefront → The price box.
A grouped product or a kit draws its set price. The buy button includes partials/kahunacart-kit.html.twig, which opens with partials/kahunacart-set-price.html.twig: the bundle price with the parts struck through, or what the parts add up to when the product isn't sold as a set. The line carries kahunacart-price kahunacart-set-price, is-on-sale when the set saves money, and kahunacart-compare-price on the struck-through total. A theme that prints its own lead price passes show_set_price: false to the buy button, or includes the set price partial itself with class, was_class and sale_class set to its own classes. Include it with ignore missing if the theme must also run on a KahunaCart older than 1.5.7. The Kahuna theme does this from 1.0.16. See Storefront.
Print flash messages raw. Every message KahunaCart puts in Grav's message queue is already escaped, with only its links and bold drawn, so a "Sign in" in a refusal is something the shopper can click. Grav's own messages partial prints message.message raw, and so does partials/kahunacart-messages.html.twig. A theme that overrides it should do the same. For a sentence handed to a template, such as a cart line note, use |kahunacart_message. See Messages with a link in them.
Render the buy button outside the storefront routes
A quick-view modal or a product card with an inline buy form sits on a route where the script is not enqueued for you.
-
Add the script in your template:
TWIG{% do assets.addJs('plugin://kahunacart/assets/kahunacart-options.js', {loading: 'defer'}) %} -
If you inject the buy form after page load, call
window.kahunacartOptions.start(container).
The picker is now active on that page.
Pre-orders and downloads in a theme
These are the facts a theme reads to draw a pre-order, a date, a downloads library or a digital variant.
Pre-orders
While a product's release date is ahead, the submit button says Pre-order (PLUGIN_KAHUNACART.PREORDER) instead of Add to cart or Buy now. A button label the merchant wrote still wins. The button carries kahunacart-preorder, and a .kahunacart-preorder-note paragraph after it prints when the product is out ("Out Friday, November 13").
The rest of the storefront follows:
- Cart and checkout lines get "Pre-order · out Nov 13" through
kahunacart_cart_line_notes(). - Placed order lines print
item.preorder.textin.kahunacart-line-preorder. - A held download prints
grant.soon("Soon · Nov 13") in.kahunacart-download-soon, where its link would be. - The badge partial adds a
kahunacart-badge--preorderbadge.
A theme that draws its own card button, hero tag or record section reads the same facts from kahunacart_preorder(product):
{% set pre = kahunacart_preorder(product) %}
{% if pre.active %}
<button>{{ 'PLUGIN_KAHUNACART.PREORDER'|t }} · {{ kahunacart_price(product.default_variant.price_minor) }}</button>
<p>{{ pre.date_label }}</p>
{% endif %}
While the product is a pre-order, it answers these keys, written in the store's timezone and the language being browsed, with the year when it is not this year's:
| Key | Holds |
|---|---|
active |
true |
release_at |
The release time, in epoch seconds |
date_label |
"Out Friday, November 13" |
short_label |
"Pre-order · out Nov 13" |
date |
"Friday, November 13" |
short_date |
"Nov 13" |
For anything else (an ordinary product, one already out, a missing argument) it answers an empty array and never throws, so {% if pre.active %} is the whole test. See Storefront.
Dates in the store's own order
kahunacart_date(at) writes a date the way the store's language does ("November 19, 2026" in US English, "19 November 2026" in British English), in the store's timezone. kahunacart_date(at, 'day') leaves the year off. Use it instead of a |date('j F Y') pattern, which bakes one country's order into the theme. See Storefront.
A downloads library of your own
kahunacart_downloads(limit) answers the signed-in customer's downloads, newest first, for a theme that draws its own library on the home page, the account or a landing page. A limit of 0, or none, is every download.
{% for file in kahunacart_downloads(6) %}
<article class="download">
{% if file.image %}<img src="{{ file.image }}" alt="">{% endif %}
<h3>{{ file.product_title }}{% if file.variant_title %} <small>{{ file.variant_title }}</small>{% endif %}</h3>
{% if file.held %}
<span>{{ file.title }} · {{ file.soon_label }}</span>
{% else %}
<a href="{{ file.url }}">{{ file.title }}</a>
{% endif %}
</article>
{% endfor %}
Each row holds these keys:
| Key | Holds |
|---|---|
title |
The file's own title, or its file name |
product_title, product_slug |
The product it came from, in the language being browsed |
variant_title |
The format it was bought in, such as "Amber vinyl LP", or an empty string |
image |
The product's first picture URL, or an empty string |
url |
The download link |
held |
True while a pre-order holds the file |
soon_label |
"Soon · Nov 13" while held |
order_number |
The order the file came with |
While a pre-order holds the file, url is null. Print soon_label in place of a link, because the link is refused until the release day. The list is the same one the account's Downloads page draws, and files that belong to a release (the Releases list) come in it too. It is an empty list when nobody is signed in, when the customer has no downloads and on anything that goes wrong, never an error.
Digital variants
A record's Download format beside its pressings is a variant with is_digital: 1 on a physical product (Digital variants). The variant rows a theme reads (product.variants, the option picker's payload) carry it, so a format list can say "Download" or skip the stock line for it. Its cart and order lines carry is_digital like a digital product's, so nothing else changes for a theme.
Structured custom fields
The shipped custom fields partial (partials/kahunacart-custom-fields.html.twig) leaves out a field whose value is a list or a map (row.structured in custom_fields.forDisplay), such as a record's track list in a json field. A theme draws that itself from kahunacart_custom_fields('product', product.id).
The store status
A theme asks one function whether the store is open and whether this visitor may buy, rather than reading the configuration itself:
{% set store = kahunacart_store_status() %}
{% if store.buying %}
<a class="cart-link" href="{{ kahunacart_url('/cart') }}">Cart</a>
{% endif %}
kahunacart_store_status() answers:
| Key | Meaning |
|---|---|
status |
open, coming_soon or paused |
open |
True only while the status is open |
buying |
True when this visitor may buy: an open store, or signed-in staff in a closed one. Draw the cart link, the basket badge and any buy control of your own on this |
preview |
True for signed-in staff looking at a closed store, who can still buy |
noindex |
True while the store is coming soon |
message |
The store's sentence for the status, the merchant's own or the standard one. It can carry a link, so print it with {{ store.message\|kahunacart_message }} |
custom |
True when the merchant wrote the message |
You don't have to guard the shipped buy button. partials/kahunacart-buy-button.html.twig already draws Coming soon or Paused where the button was, as a paragraph with kahunacart-buy-unavailable kahunacart-store-closed kahunacart-store-closed--{status}. A button of your own that you forget to guard still sells nothing, because the cart refuses the add with the store's message; the check is so you don't show a button that can't work. What a closed store refuses and what keeps working is on Configuration → Store status.
The store status banner
While the store isn't open, the plugin puts partials/kahunacart-store-status.html.twig straight after <body> on every page. To place it yourself, in your header for instance, include it:
{% include 'partials/kahunacart-store-status.html.twig' %}
The plugin looks for data-kc-store-status in the page and leaves its own copy out when it finds one. The banner is an aside with role="status". It has no close button, because a shopper who closed it would only meet the same sentence at the cart, and it draws nothing while the store is open. assets/kahunacart-store-status.css styles it on every page while it is up, on the same builtin_css terms as the rest of the stylesheet. Staff see "Your store is paused. Only staff can buy." (or "Your store isn't open yet. Only staff can buy.") in the warning colour, followed by what shoppers are told.
The cart and checkout of a closed store render kahunacart-closed.html.twig instead, with a store_status variable holding the same keys, and answer 503. Copy either file into your theme to restyle it.
The Kahuna theme (1.0.15) places the banner above its header in a store_status block, so a child theme can move it by overriding that block. It drops the cart links from the header and the footer for shoppers (staff keep theirs), and its own kahunacart-closed.html.twig shows the store's message in the theme's warning callout.
Style the catalog sidebar, tree and tags
The catalog sidebar resolves through the same token chain as the rest of the storefront. A theme that has set --kahunacart-accent and --kahunacart-fg gets the tree, the facet panel, the breadcrumb and the subcategory cards in its own colours.
Whether the filter panel is drawn at all is a setting, catalog.filters: auto draws it once some facet offers a real choice, on always draws it, off never does. Nothing else about the sidebar is configurable. With the panel off and no category tree there is no <aside> and no layout wrapper, so the listing is a flat single column. See Configuration.
The two-column layout only exists when there is a sidebar. On a store with no categories and no tags the wrapper is not emitted. See Storefront. Below 40rem the layout collapses to one column with the sidebar above the listing.
The tree is <details> and <summary> with the browser's own disclosure marker, and the summary is display: list-item. ::marker is coloured from --kc-subtle. The indent is padding on the nested list, so hover and current-page backgrounds run the full width of the rail at every depth.
Tags are not styled as category chips: a category chip goes to a place in the catalog, a tag goes to a filtered listing. If you restyle one, keep the two distinguishable.
Each attribute block in the filter panel also carries kahunacart-filter-attribute and kahunacart-filter-attribute--{type}. The shipped stylesheet paints the block through kahunacart-filter-group and the option or range classes, so the two --attribute classes are free hooks. Use them to style one type of facet differently from the rest.
A long facet keeps its overflow in the same form. Past catalog.facets.max_values the rest of a facet's tick boxes go inside a <details class="kahunacart-filter-more"> with a <summary class="kahunacart-filter-more-toggle"> reading "Show all N". The checkboxes inside are named exactly like the ones above, so ticking one submits without a request and without script. Keep the summary a summary: replacing the <details> with a button loses the no-JavaScript behaviour. Whatever the shopper has ticked is never inside the disclosure. See Storefront.
A value nothing reachable answers is disabled, not deleted. Counts follow the current filters, so a value that would give an empty grid reads 0 and renders greyed: disabled on the input, aria-disabled="true" and an is-disabled class on the <label class="kahunacart-filter-option">, with the count still printed. A whole facet with nothing reachable carries is-empty and draws a <p class="kahunacart-filter-none"> instead of its controls. A category branch that holds nothing carries is-unavailable on its <li class="kahunacart-cat-item"> and on its link, and stays a link.
A theme overriding the panel has to render all three, or a shopper is offered ticks that lead to an empty page. What a shopper has ticked is never disabled at any count, so read disabled on the value rather than keying off the count. catalog.facets.empty: hide shortens the lists in PHP before the template sees them, so an override needs no branch for it. See Storefront.
Style the results region and the filter chips
.kahunacart-catalog-results is the element a filter change replaces. It carries is-loading while a request is in flight, which dims it to opacity: 0.55 and switches its pointer events off. Nothing moves and nothing reflows.
The active filters are stated above the grid, one .kahunacart-chip per value with a Clear all at the end. They are server-rendered anchors inside the region a filter change replaces, so a theme that overrides that region has to keep including partials/kahunacart-catalog-chips.html.twig.
The cross is inside the chip link, so the whole chip is the tap target. .kahunacart-chip-remove is aria-hidden, and the accessible name on the link says what the click does.
The filter panel has the same arrangement, with three regions. A filter change requests partials/kahunacart-catalog-results.html.twig, partials/kahunacart-catalog-filters.html.twig and partials/kahunacart-catalog-tree.html.twig together and pastes them in place. An override has to keep the attribute on each outermost element: data-kc-catalog-results, data-kc-catalog-facets and data-kc-catalog-tree. The listing and category pages include the results partial and the sidebar includes the other two, so overriding a file changes the page and the swap together.
The tree is in the swap because its counts follow the filters. A theme that inlines the tree into its own sidebar gets a tree that goes stale the moment a box is ticked. See Storefront.
The pager is anchors, and there is nothing to wire. partials/kahunacart-pagination.html.twig renders inside the results region, so it moves with the grid and works with scripting off. It renders nothing when the store is not paginating (catalog.per_page: 0) or when everything fits on one page, so including it unconditionally is safe. Entries in pagination.urls whose page is null are gaps in a long pager. See Storefront.
Style a sale's quantity ladder
partials/kahunacart-sale-tiers.html.twig is one partial in three places: the whole ladder under the buy button, the same ladder in the cart, and a one-line badge on a catalogue card when it is passed style: 'badge'. It renders nothing for an offer with no tiers, so it is safe to include beside any offer.
A theme that prints prices itself must test offer.has_discount rather than offer alone. A tier-only sale is a real offer with a ladder and no money off until the cart reaches its first rung. See Sales.
Style a sectioned listing
A sectioned listing draws each block as .kahunacart-section with .kahunacart-section--hero, --grid or --compact beside it. The configured column count arrives on that element as --kahunacart-section-columns, so the merchant's Columns setting and the theme's override are the same property.
Every grid inside a block is repeat(auto-fill, minmax(…, 1fr)) bounded by that count. The browser drops to fewer columns when the container cannot hold them, and a two-column block stays two columns on a wide screen. No media query is involved, so the blocks also work inside the catalog column beside a sidebar.
A hero holding a single product takes the whole row: .kahunacart-hero-list:has(> .kahunacart-hero-card:only-child) gives that card a larger picture and title. The two-across ceiling applies to heroes with more than one product.
The grid layout has no markup of its own. partials/kahunacart-section-grid.html.twig includes the shipped product grid, so a theme that has overridden partials/kahunacart-product-grid.html.twig has already overridden every grid block.
Style a paywalled article
A page with a paywall: header is drawn by kahunacart-paywall.html.twig for a visitor who can't read it, with the subscribe panel in partials/kahunacart-paywall-panel.html.twig and the free-read bar in partials/kahunacart-paywall-meter.html.twig. The panel and the bar take their colours and radius from the storefront's custom properties, so a theme that has set those needs no extra CSS. For a reader, everything after the teaser sits in <div class="kahunacart-paywalled">, the selector the page's structured data points at. Keep that wrapper if you rewrite the article layout. See Customer accounts → Articles with a teaser for how it works, and Paywalled articles in your theme for the steps.
Cross-sells under the cart
Goes well with your order is partials/kahunacart-cart-cross-sells.html.twig, which the cart template includes under the summary. It reads cart_cross_sells, a list of at most four products. Each is the same card the product grid gets (title, slug, default_variant, image, sale and the rest), with two more fields:
| Field | What it holds |
|---|---|
thumb |
The product's picture at the cart's thumbnail size as {url, width, height}, or null when it has none |
quick_add |
The variant id an Add button posts, or null when the product has choices to make on its own page and should get a View link instead |
The list is empty, and the partial draws nothing at all, when cart.cross_sells is off, when the basket is empty, and when no product in it has cross-sells. A theme that includes the partial unconditionally is safe. See Products and catalog → Cross-sells in the cart for which products make the list.
The Add button is the ordinary add, a form posting to {base}/cart with the nonce, action=add, variant_id set to quick_add and qty=1. The shopper lands back on the cart. The shipped partial gives the button an aria-label of "Add name to cart".
To draw your own, override the partial the usual way and loop over cart_cross_sells. To turn it off for good, override the partial with an empty file, or leave the include out of your cart template. The Kahuna theme (1.0.20) overrides the partial and draws the list as compact rows through its own partials/kahuna-picks.html.twig, the same rows it uses for Goes well with this and Consider this instead on the product page.
The specification table
partials/kahunacart-product-attributes.html.twig renders a product's attributes as <table class="kahunacart-specs">, one table per group, with <th scope="row"> on the label.
The plugin's product template includes it after the buy button and before the tags. It renders nothing for a product with no attributes, so including it unconditionally is safe.
Four types draw as something other than a plain string:
| Type | Markup |
|---|---|
textarea |
The Markdown, rendered through \|kahunacart_markdown inside .kahunacart-spec-prose |
url |
An <a> with rel="nofollow noopener" |
color |
.kahunacart-swatch painted from an inline background, followed by <code class="kahunacart-spec-hex"> |
| Everything else | The display string as text |
A filterable value is wrapped in a link, <a class="kahunacart-attribute-link" rel="nofollow">, pointing at the shop narrowed to that value. A multiselect gets one link per member inside the same comma-separated cell. A colour links the swatch and its hex together. A value with no filter_url is printed as plain text, so a store with no filterable attributes gets no anchors.
The shipped stylesheet gives the class the inherited text colour and a dotted bottom border that takes the accent colour on hover and focus. Restyle it, or drop the rule and let your own anchor styling take over.
The attribute list a template reads
The product page gets attributes, and the same list is on product.attributes. Each entry is:
| Key | Holds |
|---|---|
slug |
The stable identity, the one a filter URL and an export file are made of |
label |
The merchant's label, translated for the language being browsed |
type |
One of the nine attribute types |
value |
The canonical stored value: true, "185", "#0b57d0", ["cotton", "linen"] |
display |
The string a spec table prints: Yes, 185 gsm, #0B57D0, Cotton, Linen |
unit |
The unit on a number's definition, or null |
group |
The group heading, or null for the ungrouped block |
options |
The definition's {slug, label} list, for a theme drawing its own control |
filter_url |
The listing narrowed to this value, or null when there is no link to give |
values |
On a multiselect, one entry per member as {slug, label, filter_url} |
Use display for what a customer reads and value for anything a theme compares, links or feeds to structured data. The list is in definition order and holds only the attributes marked to show that this product has a value for.
The product page also gets attribute_groups, the same list already broken into {group, items} blocks.
Include the spec table on your own page
-
Add the include to any template:
TWIG{% include 'partials/kahunacart-product-attributes.html.twig' with {attributes: kahunacart_attributes(product)} %} -
For a single fact rather than the whole table, ask for it by slug, and for its link separately:
TWIG{% set material = kahunacart_attribute(product, 'material') %} {% set link = kahunacart_attribute_url(product, 'material') %} {% if material %} <p class="material"> {% if link %}<a href="{{ link }}" rel="nofollow">{{ material }}</a>{% else %}{{ material }}{% endif %} </p> {% endif %}
| Function | Returns |
|---|---|
kahunacart_attributes(product) |
The list above, or []. |
kahunacart_attribute(product, slug) |
One attribute's display string, or null when the product does not answer it. |
kahunacart_attribute_url(product, slug) |
The listing narrowed to that value, or null when the attribute is not filterable, the product does not answer it, or the value cannot be named in a URL. |
All three take a product row, a product id or a slug. A row the plugin already decorated is read rather than fetched again, so calling any of them in a storefront template costs nothing extra.
The table sets no custom properties of its own. It paints from the same chain as everything else: --kahunacart-fg, --kahunacart-border for the row rules, --kahunacart-border-strong for the swatch outline, --kahunacart-muted for labels, --kahunacart-subtle for the section title, and --kahunacart-font-mono for the hex code.
A theme on a store with more than one currency
A theme written for one currency keeps working: core works prices out before a template sees them, and a store with one currency changes nothing. A theme that wants to be right when a store sells in more than one has these things to do. The Kahuna theme (1.0.12) does all of them and is the worked example. It needs KahunaCart 1.4.0 only for the parts that use the new partials, and runs on 1.3 and on a single-currency 1.4 without them.
- Draw the switcher. Include
partials/kahunacart-currency-switcher.html.twigin the header, and in the mobile menu if the theme has one. It draws nothing on a store with one currency. See The currency switcher. - Say "Not available in EUR" where a price would be, and take the buy form away. Check
unavailable_in_currencyon the product, the card and the variant, and includepartials/kahunacart-unavailable-in-currency.html.twig. A sale test of your own (compare_price_minor > price_minor) must also requireprice_minor is not null. See Prices on a store with more than one currency. - Build links with
kahunacart_url()and the whole path, post the buy form tokahunacart_url('/cart'), and give a GET search or filter form the hiddencurrencyfield. See Links on a store that sells in more than one currency. -
Say why checkout has no way to pay. When no payment method takes the order's currency, core sets
providers_excluded_currency(the code) and draws a sentence in its own checkout. A theme that overrideskahunacart-checkout.html.twigneeds this block in the payment fieldset, directly after theproviders_excluded_physicalblock and starting on the line of its closing{% endif %}, so a checkout without the case prints the same bytes as before:TWIG{% endif %}{% if providers_excluded_currency|default('') %} <p class="kahunacart-notice kahunacart-provider-unavailable">{{ 'PLUGIN_KAHUNACART.CHECKOUT_PROVIDER_NO_CURRENCY'|t(providers_excluded_currency) }}</p> {% endif %}Without it a shopper in that currency sees a payment step with nothing in it. When the currency is the reason, the digital-only line doesn't show, because it would be untrue.
- Pass the order's currency for anything that belongs to an order. Catalogue prices need nothing. A sale-tier hint in the cart, a receipt line, an account page, an email:
kahunacart_price(order.total_minor, order.currency). A line that leaves it out prints the right number in the visitor's currency, not the order's. - Show the notice a currency switch leaves behind on every page. Switching an open cart's currency queues a message saying what was repriced and what was taken out, and the switcher sits in the header of every page, so a shopper can come back to any of them. The shop pages draw queued messages. A home page, a content page or a modular page may not, so draw Grav's messages there when the theme has a switcher, or the notice waits unread for the next shop page.
- Include the cart currency notice. Shop-page overrides should include
partials/kahunacart-currency-notice.html.twigbelow the messages. It explains when a cart keeps the page in its currency and offers a switch or a new cart after a hosted payment. Seekahunacart_currency_notice(). - Bring overrides up to date. Run
bin/plugin kahunacart templates:driftand copy the 1.4.0 changes into the listed overrides. The product, category, search, product-list, cart and checkout pages and their partials changed.kahunacart-currency,kahunacart-currency-switcherandkahunacart-currency-noticeare new;kahunacart-unavailable-in-currencyis also new in 1.4.0.
Links on a store that sells in more than one currency
A shopper who is not in the store's base currency stays on URLs that name theirs, so a shared cache can keep the page and a cart they start is made in the currency they were shown. kahunacart_url() does it for you: a path to a product, a category, the search page or the cart comes back with ?currency=EUR on it for that shopper, and with nothing added for a visitor in the base currency or on a store with one currency, except that a store reading a country header names the base currency too (?currency=USD), because its plain URLs are private. Which means a theme that already builds its links with kahunacart_url('/product/' ~ product.slug) and posts its forms to kahunacart_url('/cart') needs no change.
<a href="{{ kahunacart_url('/product/' ~ product.slug) }}">…</a> {# /shop/product/tee?currency=EUR #}
<a href="{{ kahunacart_url('', true) }}">Shop</a> {# the front door, with the currency on it #}
<form method="post" action="{{ kahunacart_url('/cart') }}">…</form> {# starts a cart in the currency the page showed #}
Three things a theme has to know.
-
Pass the whole path in. Don't build
kahunacart_url() ~ '/product/' ~ slugany more, and don't add?variant=2or#reviewsto whatkahunacart_url()returned for a catalogue path. The front door with no path (kahunacart_url()) is the one URL that never carries the currency by itself, because it is also the base themes join their own paths onto;kahunacart_url('', true)is the front door as a link. The second argument istrueto carry the currency whatever the path,falseto leave it off, and left out to let the path decide. -
A form that submits by GET needs a hidden field. A browser throws away the query string on a GET form's
action, so the filter panel and the search box carry the currency as<input type="hidden" name="currency" value="EUR">. Every storefront page is handedkahunacart_currency(the code, absent for a visitor in the base currency) for exactly this, and the filter panel'sfilters.currencyis the same value:TWIG{% if kahunacart_currency|default('') %}<input type="hidden" name="currency" value="{{ kahunacart_currency }}">{% endif %} -
Leave the canonical alone. A canonical link,
og:url, structured data and sitemap entries name the page without its currency query. Feed landing links carry the feed’s currency.kahunacart_canonicaland the SEO partial already build the canonical address. -
A shop at the site root has an empty
kahunacart_base. Withroute: '/'on a domain root,kahunacart_baseis empty so{{ kahunacart_base }}/cartstill gives/cart. Link to the front door itself withkahunacart_url(), which gives/there. See Storefront → Mounting the shop at the site root.
A theme override of kahunacart-product-grid, kahunacart-section-hero, kahunacart-section-compact, kahunacart-categories, kahunacart-breadcrumbs, kahunacart-search, kahunacart-catalog-filters, kahunacart-reviews, kahunacart-product.html.twig, kahunacart-category.html.twig and kahunacart-search.html.twig is now behind the shipped template: copy the link changes across.
Two things that are not storefront links and are worth naming, because both look like they should be:
- The site's own home page is
base_url_absolute, not/. A bare/is the server root, which on a subdirectory install belongs to somebody else. - Assets go through Grav's own
url()function ({{ url('theme://images/logo.svg') }}), which prefixes the root itself.
If you are writing a theme with no PHPUnit suite of its own, grep -rn 'kahunacart_base_route()' templates/ is the whole audit: every hit should be a comparison, never an href or an action. The Kahuna theme ships that check as a script — see tests/check-storefront-links.php there.
Header, footer and account links
Wrap a complete site-navigation URL in kahunacart_currency_link(). A visitor arriving on ?currency=EUR keeps that currency on Home, About, brand, sign-in and account links, even before a cookie exists:
<a href="{{ kahunacart_currency_link(item.url) }}">{{ item.title }}</a>
Existing query parameters and fragments are kept. Cookie-only choices keep plain navigation URLs. Single-currency links, external hosts, email links and fragment-only links stay as supplied. Use kahunacart_url() for links within the storefront. Feed landing links carry the feed’s currency; canonical links and the sitemap do not.
Prices on a store with more than one currency
A store can sell in more than one currency. Core resolves the prices before a template sees them, so a theme that prints variant.price_minor raw, or product.default_variant.price_minor, or offer.sale_minor, prints the visitor's price with no change, and a theme written for one currency keeps working. What a theme needs to know is three things.
The same keys now mean the visitor's currency. price_minor, compare_price_minor, sale_minor and regular_minor on a variant row, a product card, a sale offer, a price-list offer, a quantity-tier table, the pay-what-you-want terms, a fixed add-on price and a wishlist item are in the currency the visitor is browsing in. For a visitor in the store's base currency, and on a store with one currency, they are what they were. kahunacart_price(minor) with no second argument formats in the same currency. Pass an order's own currency for anything that belongs to an order, a refund or a subscription, as a theme has always had to.
A new key says a price is missing. A variant with no price in the visitor's currency has price_minor: null, compare_price_minor: null and unavailable_in_currency: true. A product with no priced variant at all carries unavailable_in_currency: true too. The key is absent when the product or variant can be bought, so product.unavailable_in_currency|default(false) is all a template needs. kahunacart_price(null) prints an empty string and never 0.00, so a theme that does not know about the key draws a blank where the price would be, and never a free product or a failed page. A theme should still say why and take the add-to-cart form away.
A partial draws the state. partials/kahunacart-unavailable-in-currency.html.twig is the sentence to print where the price and the form would be:
{% if product.unavailable_in_currency|default(false) %}
{% include 'partials/kahunacart-unavailable-in-currency.html.twig' with {product: product} only %}
{% else %}
…the price and the buy button…
{% endif %}
With style: 'card' it is the small muted line for a card, a row or a wishlist item, with no link. Without it, it is "Not available in EUR." and a link to the product in the store's base currency, ?currency=USD. For a shopper whose cart has lines in it the cart decides the currency and that link would do nothing, so the partial draws a "Switch my cart to USD" button (the switcher's own post) in its place, and nothing at all when the cart is held at a payment provider. Both read kahunacart_current_currency() for the codes, and pinned says a cart decides. The shipped product grid, hero, compact row and wishlist partials do exactly this.
Three more places a theme that draws its own markup has to follow:
- A theme with its own variant select disables the option of a variant that has
unavailable_in_currencyand prints the reason where its price would be, so a shopper is not left with "Sand —" and nothing after the dash. - A theme with its own option picker script reads
priced: falseon a row ofoptions_payload, which is the same fact: that variant has no price,availableis false, andprice,price_htmlandcompare_htmlare null. The textnot_priced("Not available in EUR") is in the payload'stexton such a store. The shippedkahunacart-options.jstreats such a variant as a combination that does not exist: the value only it offers is disabled the way an impossible one is, with the reason in itstitleand, in a dropdown, in the option's text. - A theme that decides "on sale" itself by comparing
compare_price_minorwithprice_minoris still right, because a missing compare-at price is null and null is never greater. A product on sale in dollars with no compare-at price in euro is not struck through in euro. Usekahunacart_sale()for a sale, which works out percentages on the visitor's price and gives nothing for a fixed amount that has none in the currency.
Whole amounts and the word for the cart
Two store settings change how the storefront reads. A theme gets both for free as long as it prints prices with kahunacart_price() and strings with |t.
Whole amounts
Whole amounts (storefront.whole_amounts, off as shipped) prints $55 instead of $55.00. A price with cents keeps them, so a sale at $49.50 is never rounded. Prices shown together are printed alike: when a theme draws a pair, it names the other price with with, and if either has cents both keep them.
<s>{{ kahunacart_price(offer.regular_minor, with: offer.sale_minor) }}</s>
<span>{{ kahunacart_price(offer.sale_minor, with: offer.regular_minor) }}</span>
{# a ladder: every rung beside every other #}
{% set amounts = offer.tiers|filter(t => t.mode != 'percent')|map(t => t.value) %}
{% for tier in offer.tiers %}{{ kahunacart_price(tier.value, with: amounts) }}{% endfor %}
The plugin's own partials already do this for the card, the buy button and its variant select, the option picker's prices, set prices, the tier ladder and the pay-what-you-want minimum and maximum.
A cart or an order is one set. The lines, the subtotal, the discounts, the shipping, the tax, the fees, the store credit, the deposit rows and the total are printed alike. A cart with a sale line reads "$58.00 · -$17.40 · $6.00 · $46.60", and a cart of whole prices reads "$58 · $6 · $64".
The cart, checkout, pay and receipt pages carry the amounts of their summary as price_set, and the plugin's partials pass it as with on every price in the summary. A theme that overrides one of them does the same:
<td>{{ kahunacart_price(item.total_minor, order.currency, price_set|default([])) }}</td>
For a summary a theme draws where the page has no price_set, such as a mini cart in the header, kahunacart_price_set(vars) builds the list from order, items, adjustments and the rest. The theme passes it as with the same way:
{% set mini = kahunacart_price_set({order: order, items: items, adjustments: adjustments}) %}
Some amounts are sets of their own:
- The shipping and tax estimate on the cart is priced for an address and never stored, so its amounts are one set and the totals above it are another.
- Each order in the account's order list is a set of its own.
- The invoice, credit notes and reports always print full amounts, and so do emails, feeds and the API, which never call
kahunacart_price(). - The storefront receipt page follows the setting.
kahunacart_whole_amounts() answers the setting for a theme that formats a number some other way. A price an add-on hands over already formatted (Bookings' service.price, for example) is formatted in full. Print its price_minor with kahunacart_price() instead to follow the setting.
The word for the cart
The word for the cart (storefront.cart_word: cart, bag or basket) changes every storefront string that names the cart: "Add to bag", "Added to your bag.", "Your bag" (the page title, in sentence case for every word), "Your bag is empty.", "Update bag", the currency and coupon messages and the rest.
The plugin's language file keeps the cart wording where it always was, and one set of replacements per word under CART_WORDS. On a storefront request for a store set to bag, each CART_WORDS.BAG string replaces the string of the same name, so templates never test the word. For a theme's header, mini cart and buttons there are strings that follow it too:
| Key | Cart | Bag |
|---|---|---|
PLUGIN_KAHUNACART.CART_LINK |
Cart | Bag |
PLUGIN_KAHUNACART.VIEW_CART |
View cart | View bag |
PLUGIN_KAHUNACART.BACK_TO_CART |
Back to cart | Back to bag |
PLUGIN_KAHUNACART.CART_COUNT_ONE |
1 item in your cart | 1 item in your bag |
PLUGIN_KAHUNACART.CART_COUNT |
%d items in your cart | %d items in your bag |
PLUGIN_KAHUNACART.ADD_NAMED_TO_CART |
Add %s to your cart | Add %s to your bag |
PLUGIN_KAHUNACART.ADD_TO_CART |
Add to cart | Add to bag |
PLUGIN_KAHUNACART.CART |
Your cart | Your bag |
PLUGIN_KAHUNACART.ADDED_TO_CART |
Added to your cart. | Added to your bag. |
PLUGIN_KAHUNACART.CART_EMPTY |
Your cart is empty. | Your bag is empty. |
A theme with strings of its own that name the cart ships its own sets beside them, and they follow the same setting:
en:
THEME_MYTHEME:
HEADER_CART: Cart
CART_WORDS:
BAG:
HEADER_CART: Bag
BASKET:
HEADER_CART: Basket
A theme that restates one of the plugin's cart strings in its own language file should restate the bag and basket versions too, under PLUGIN_KAHUNACART.CART_WORDS. Otherwise the plugin's wording replaces its own for those stores. A translation with no sets keeps its own cart strings, and a set missing a key keeps that key's cart string.
kahunacart_cart_word() answers the word, for a theme that picks an icon or a class by it. The admin and the API are not changed: the merchant's word for the feature is cart.
The currency switcher
On a store that sells in more than one currency the shopper needs somewhere to pick one. Core's own pages do not draw it, because the right place is your theme's header, next to the cart and account links. Include the partial there:
{% include 'partials/kahunacart-currency-switcher.html.twig' %}
It draws nothing at all when the store sells in one currency, so the line is safe in a header on every store. Otherwise it is a <details> menu, which opens and closes with no JavaScript:
<details class="kahunacart-currency-switcher">
<summary class="kahunacart-currency-switcher-current" aria-label="Currency: EUR">EUR</summary>
<ul class="kahunacart-currency-switcher-list">
<li class="kahunacart-currency-switcher-item"><a class="kahunacart-currency-switcher-option" href="/shop?currency=USD" rel="nofollow">US Dollar ($)</a></li>
<li class="kahunacart-currency-switcher-item is-active"><span class="kahunacart-currency-switcher-option" aria-current="true">Euro (€)</span></li>
</ul>
</details>
The current currency is the summary and a marked line in the list. Another currency is a link, or, when the shopper has a cart open in a different currency, a small form with a button, because picking it moves the cart and a link must not change a cart:
<li class="kahunacart-currency-switcher-item"><form method="post" action="/shop/cart/currency" class="kahunacart-currency-switcher-form">
…the page's security token, a hidden currency and a hidden back address…
<button type="submit" class="kahunacart-currency-switcher-option">Euro (€)</button>
</form></li>
What to style: .kahunacart-currency-switcher (the menu), -current (the summary), -list, -item (with .is-active), -option (the link, the button and the marked line all carry it, so one rule styles the three) and -form. The shipped rules use the --kc-* tokens and sit inside :where(), so a plain class selector in your theme wins. The class reference below lists them.
Draw it yourself. kahunacart_currencies() is a Twig function that returns the list, empty on a store that sells in one currency, with nothing looked up. Each entry has:
| Key | Holds |
|---|---|
code |
The currency code, EUR. |
label |
The currency's name and symbol in the shopper's language, "Euro (€)". The code where PHP's intl extension is missing. |
active |
True for the currency this request is in. Exactly one entry. |
url |
The page the shopper is on with ?currency=CODE on it, the rest of the query kept. Use it with rel="nofollow". |
switches_cart |
True when picking this currency would move the shopper's open cart. Draw a form that posts, not a link. |
action |
Where that form posts: {base}/cart/currency. |
back |
The page the shopper is on, without a currency, which goes in a hidden back field. |
title |
A sentence to put on the control, empty unless there is something to say: the country decides the currency at checkout, or a payment page is open. |
A form needs the page's security token as every other KahunaCart form does ({{ nonce_field('kahunacart', 'nonce')|raw }}), a hidden currency with the entry's code and a hidden back with its back. A post whose back is not a page of this store lands on the store's front page instead.
A theme with its own base template. Copy partials/kahunacart-currency-switcher.html.twig into your theme's templates/partials/ to change the markup, and the plugin's stylesheet will keep styling whatever class names you keep. The question page a shopper sees when a switch would empty their cart is kahunacart-currency.html.twig, and a theme can override it like any other page template. Its classes are kahunacart-currency-confirm, -intro, -lines, -form, -submit and -links.
Set the card picture's size
A product card's picture is cropped to 600 × 450 (4:3) so a grid of photographs shot at different ratios lines up. A theme that draws its cards at another ratio asks for its own box in its theme config, so the browser doesn't crop the picture a second time:
# user/themes/<your-theme>/<your-theme>.yaml
kahunacart:
card_size: [600, 600] # width, height in pixels; square cards
Each side must be between 200 and 1600 pixels. Anything else falls back to 600 × 450. The wishlist's cards use the same box. The gallery's large image is never cropped.
Choose between a variable, extra CSS, and a template override
Three levels, cheapest first.
| Level | Use it when | Update cost |
|---|---|---|
| Set a custom property | You are changing colour, radius, typeface or motion | None |
| Write your own CSS rules | You want different spacing, a different card layout, or a component that looks structurally unlike what ships | None |
| Extend a template | The markup is wrong for you: a missing product image, a two-column checkout, a different element order for screen readers, or your own class names | None. What you don't override stays the plugin's |
| Copy a template | Blocks can't do what you need | The copy is frozen at the version you took it from |
Plugin rules sit at low specificity, so a plain .kahunacart-product-card { … } in your theme wins. If you need !important, report it as a bug.
To change the markup, extend the page and override only the regions you need: {% extends '@kahunacart/kahunacart-checkout.html.twig' %} and a block or two. Everything you didn't override stays the plugin's, so the next release's changes still reach you. A single product card, cart line or receipt line is a partial of its own, so restyling one is overriding one small file. See Theme blocks.
Copying a whole template into user/themes/<your-theme>/templates/ at the same relative path still works, and your theme's copy still wins. It is the last resort. See Storefront for the full template list.
Warning
A copied template does not receive later KahunaCart changes to that file, including added fields and accessibility fixes. Change markup when you need markup, not when you need a color.
Class reference
What the stylesheet targets, grouped by page. Every class here is rendered by the shipped templates.
| Area | Classes |
|---|---|
| Wrapper | kahunacart on every page, plus one of kahunacart-products, kahunacart-category, kahunacart-product, kahunacart-cart, kahunacart-checkout, kahunacart-order, kahunacart-processing, kahunacart-denied, kahunacart-account, kahunacart-search-results, kahunacart-invoice, kahunacart-closed |
| Listing | kahunacart-product-grid, kahunacart-product-card, kahunacart-card-link, kahunacart-card-media, kahunacart-price, kahunacart-listing-empty, kahunacart-listing-more |
| Sectioned listing | kahunacart-section with kahunacart-section--hero, --grid or --compact, kahunacart-section-title on the heading, kahunacart-hero-list, kahunacart-hero-card (.has-image), kahunacart-hero-media, kahunacart-hero-body, kahunacart-hero-title, kahunacart-hero-summary, kahunacart-hero-action, kahunacart-compact-list, kahunacart-compact-item (.has-image), kahunacart-compact-link, kahunacart-compact-media, kahunacart-compact-title, kahunacart-compact-price |
| Categories | kahunacart-categories, kahunacart-category-chip (.is-active), kahunacart-category-count, kahunacart-category-description, kahunacart-category-media |
| Catalog layout | kahunacart-catalog-layout, kahunacart-catalog-main, plus kahunacart-has-nav on the page wrapper when there is a sidebar |
| Category tree | kahunacart-catalog-nav, kahunacart-nav-title, kahunacart-cat-tree, kahunacart-cat-list, kahunacart-cat-item, kahunacart-cat-branch, kahunacart-cat-summary, kahunacart-cat-link, kahunacart-cat-count, plus the state classes is-current, is-ancestor and is-unavailable on a branch holding nothing under the filters currently on |
| Filter panel | kahunacart-filters, kahunacart-filter-group, kahunacart-filter-options, kahunacart-filter-option, kahunacart-filter-count, kahunacart-filter-price, kahunacart-filter-range, kahunacart-filter-field, kahunacart-filter-dash, kahunacart-filter-actions, kahunacart-filter-clear, plus kahunacart-filter-attribute and kahunacart-filter-attribute--{type} on each attribute block, kahunacart-filter-more and kahunacart-filter-more-toggle on the Show all disclosure under a facet the cap has shortened, and kahunacart-filter-none on the "None match the current filters" line. The state classes are is-disabled on a value nothing reachable answers, with disabled on its input and aria-disabled on its label, and is-empty on an attribute block with nothing reachable in it |
| Results region | kahunacart-catalog-results on the element a filter change replaces, plus is-loading on it while a request is in flight. The pager renders inside it |
| Pagination | kahunacart-pagination, kahunacart-pagination-summary, kahunacart-pagination-list, kahunacart-pagination-prev, kahunacart-pagination-next, kahunacart-pagination-current, kahunacart-pagination-gap |
| Sale tiers | kahunacart-sale-tiers, kahunacart-sale-tiers-heading, kahunacart-sale-tier-list, kahunacart-sale-tier (.is-current on the rung in force), kahunacart-sale-tiers-basis, kahunacart-sale-tier-badge on the catalogue card's one-line teaser, and kahunacart-sale-tier-state / -current / -next on the cart's nudge |
| Active-filter chips | kahunacart-filter-chips, kahunacart-chip-list, kahunacart-chip, kahunacart-chip-label, kahunacart-chip-remove, kahunacart-chip-clear |
| Search | kahunacart-search, kahunacart-search-field, kahunacart-search-summary, kahunacart-search-more |
| Breadcrumb | kahunacart-breadcrumbs, kahunacart-breadcrumb-list, kahunacart-breadcrumb-item |
| Subcategory cards | kahunacart-subcategory-grid, kahunacart-subcategory-card (.has-image), kahunacart-subcategory-link, kahunacart-subcategory-media, kahunacart-subcategory-title, kahunacart-subcategory-count |
| Tags | kahunacart-tags, kahunacart-tags-title, kahunacart-tag-list, kahunacart-tag |
| Specification table | kahunacart-attributes, kahunacart-attributes-title, kahunacart-specs, kahunacart-specs-group, kahunacart-spec with kahunacart-spec--{type}, kahunacart-spec-label, kahunacart-spec-value, kahunacart-spec-prose, kahunacart-swatch, kahunacart-spec-hex, kahunacart-attribute-link on a value that links to the listing narrowed to it |
| Product | kahunacart-summary, kahunacart-description, kahunacart-buy, kahunacart-qty, kahunacart-compare-price, kahunacart-sale-badge, kahunacart-tax-note (also on grid and hero cards, carrying data-kc-tax-note), kahunacart-backorder-note |
| Set price | kahunacart-set-price beside kahunacart-price on the price line of a grouped product or a kit, is-on-sale when the set saves money, data-kc-set-price on the line |
| Price box | kahunacart-pwyw around the pay-what-you-want box, kahunacart-pwyw-label, kahunacart-pwyw-input, kahunacart-pwyw-hint |
| Product gallery | kahunacart-gallery, kahunacart-gallery-main, kahunacart-gallery-slide, kahunacart-gallery-thumbs |
| Option picker | kahunacart-options, kahunacart-option, kahunacart-option-name, kahunacart-option-select, kahunacart-option-choices, kahunacart-option-choice with kahunacart-option-pill or kahunacart-option-swatch, kahunacart-option-dot, kahunacart-option-label, kahunacart-option-status, kahunacart-variant-picker, plus the state classes is-selected, is-unavailable and is-sold-out |
| Cart | kahunacart-cart-table, kahunacart-remove, kahunacart-checkout-link, kahunacart-cart-account |
| Cart lines and sets | kahunacart-cart-product around a line's picture and name, kahunacart-cart-thumb on the picture, kahunacart-cart-name, kahunacart-cart-set on the row a grouped product heads its parts with, kahunacart-cart-part on each part under it, kahunacart-set-picked on the line naming a set's picks, kahunacart-line-note on a note under a name, and kahunacart-qty-fixed on the quantity of a part you can't change |
| Grouped product buy form | kahunacart-kit (is-grouped on a grouped product) around the parts list and its form, kahunacart-kit-items, kahunacart-kit-item, kahunacart-kit-qty, kahunacart-kit-empty, kahunacart-buy--kit on the form, and kahunacart-kit-picks around the dropdowns of the parts the shopper picks, with kahunacart-kit-pick on each label and kahunacart-kit-pick-name on the part's name |
| Cross-sells under the cart | kahunacart-relations and kahunacart-cart-cross-sells on the section, kahunacart-relations-title on the heading, kahunacart-cart-cross-sells-list on the list, kahunacart-cart-cross-sell on each row, kahunacart-cart-cross-sell-link around the picture and name, kahunacart-cart-cross-sell-title on the name, kahunacart-cart-cross-sell-price on the price, with kahunacart-cart-thumb on the picture and kahunacart-compare-price on a struck-through price |
| Bundle suggestions | kahunacart-bundle-suggestions on the region, kahunacart-bundle-suggestion on each notice, kahunacart-bundle-suggestion-actions around the two buttons, kahunacart-button-secondary on Keep them separate |
| Sold out | kahunacart-sold-out on a disabled buy button, plus kahunacart-sold-out--out-of-stock, --not-available, --coming-soon or --custom |
| Store status | kahunacart-store-status with --coming_soon, --paused and --preview, carrying data-kc-store-status, plus kahunacart-store-status-body, -lead and -text; kahunacart-store-closed with --coming_soon or --paused on the sentence where the buy button was; kahunacart-closed with --coming_soon or --paused, and kahunacart-closed-message, on the closed cart and checkout page. See The store status |
| Coupon | kahunacart-coupon, kahunacart-coupon-code, kahunacart-coupon-remove, kahunacart-coupon-active |
| Totals | kahunacart-totals, kahunacart-adjustment with --discount, --shipping or --tax, kahunacart-total |
| Checkout | kahunacart-checkout-form, kahunacart-checkbox, kahunacart-provider, kahunacart-shipping-methods, kahunacart-shipping-method, kahunacart-shipping-hint, kahunacart-shipping-none, kahunacart-place-order |
| Order | kahunacart-order-meta, kahunacart-order-items, kahunacart-order-links, kahunacart-offline-instructions |
| Order status badges | kahunacart-status with kahunacart-status--payment, kahunacart-status--order or kahunacart-status--fulfillment, plus kahunacart-status--{value} for the status value itself |
| Downloads | kahunacart-downloads, kahunacart-download-list, kahunacart-download, kahunacart-download-product, kahunacart-download-meta |
| Customer account | kahunacart-account-nav, kahunacart-account-nav-item (--current, --logout), kahunacart-account-panel, kahunacart-account-identity, kahunacart-account-orders, kahunacart-account-order-actions, kahunacart-account-order-history, kahunacart-account-addresses, kahunacart-account-downloads, kahunacart-account-empty |
| Addresses | kahunacart-address, kahunacart-address-form, kahunacart-address-grid, kahunacart-address-list, kahunacart-address-card (--default), kahunacart-address-flags, kahunacart-address-flag, kahunacart-address-actions, kahunacart-address-edit, kahunacart-shipping-address |
| Address check | kahunacart-address-check, kahunacart-address-check-title, kahunacart-address-check-lead, kahunacart-address-check-warning, kahunacart-address-check-options, kahunacart-address-check-option, kahunacart-address-check-label, kahunacart-address-check-suggested, kahunacart-address-check-accept, kahunacart-address-check-keep |
| Invoice | kahunacart-invoice-toolbar, kahunacart-invoice-header, kahunacart-invoice-store, kahunacart-invoice-parties, kahunacart-invoice-party, kahunacart-invoice-meta, kahunacart-invoice-items, kahunacart-invoice-tax-id, kahunacart-invoice-email, kahunacart-invoice-footer |
| Denial | kahunacart-denied-message, plus kahunacart-denied--{reason} where reason is not_found, revoked, expired, exhausted, order_refunded or unreadable |
| Messages | kahunacart-messages, kahunacart-message with --error, --warning, --info or --success |
| Utility | kahunacart-hint, kahunacart-link-button (--danger), kahunacart-visually-hidden, kahunacart-no-print |
The --{modifier} variants on adjustments, denials, statuses and messages are always rendered, whether or not the shipped stylesheet paints them, so you can target any of them from your own CSS. The stylesheet leaves most denial reasons looking identical.
Payment totals in a checkout override
Keep id="kahunacart-checkout-summary" on the totals and Place order block, and take the new payment block from kahunacart-checkout.html.twig. Choosing a delivery or payment method saves the choice and replaces the delivery options, payment choices and summary. The summary may be inside or beside the payment block. Without its id, the displayed total can remain from the previous choice.
During that update, the delivery step, payment block and summary carry aria-busy="true". Include the translated PAYMENT_FEE_NOTE in a <noscript> paragraph when payment methods have fees. It explains that the fee can change the displayed total. Required terms are still checked when placing the order. Choosing collection completes the delivery step; choosing delivery again requires a method.
A new-cart notice in a checkout override
kahunacart_currency_notice() offers can_switch or can_restart. Render its text and one form when either is true. The form posts to action with the usual nonce, currency=asked and back. A restart also needs restart=1 and source. Use label for the button text. Checkout passes the same object as currency_restart_notice after a country-lock refusal. Include partials/kahunacart-currency-notice.html.twig with {kc_notice: currency_restart_notice} if your checkout does not use core’s messages partial. These notices belong only on private pages.
Related
- Theme blocks — change a page's markup by extending it instead of copying it
- Storefront — routes, templates and Twig functions
- Products and catalog — what the catalog pages render
- Multilanguage — what a theme needs when the store runs more than one language
- Currencies — selling in more than one currency