Skip to content

Powered by Grav CMS + Helios Theme

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:

TXT
--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.

  1. Open your theme's CSS file.

  2. 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 */
    }
    
  3. 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.

  1. Add a block under your theme's dark-mode selector:

    CSS
    html[data-theme="dark"] {
       --kahunacart-fg: #e6e8ee;
       --kahunacart-bg: #14161c;
       --kahunacart-card: #1b1e26;
       --kahunacart-accent: #7cc9b8;
    }
    
  2. 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

  1. Set builtin_css to false, or use the matching toggle on the General tab of the plugin's admin settings.
  2. 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 ship hidden.
  • 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_id and qty.
  • A theme listening for change on 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.

  1. Add the script in your template:

    TWIG
    {% do assets.addJs('plugin://kahunacart/assets/kahunacart-options.js', {loading: 'defer'}) %}
    
  2. 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.text in .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--preorder badge.

A theme that draws its own card button, hero tag or record section reads the same facts from kahunacart_preorder(product):

TWIG
{% 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.

TWIG
{% 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:

TWIG
{% 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:

TWIG
{% 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

  1. Add the include to any template:

    TWIG
    {% include 'partials/kahunacart-product-attributes.html.twig'
      with {attributes: kahunacart_attributes(product)} %}
    
  2. 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.

  1. Draw the switcher. Include partials/kahunacart-currency-switcher.html.twig in 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.
  2. Say "Not available in EUR" where a price would be, and take the buy form away. Check unavailable_in_currency on the product, the card and the variant, and include partials/kahunacart-unavailable-in-currency.html.twig. A sale test of your own (compare_price_minor > price_minor) must also require price_minor is not null. See Prices on a store with more than one currency.
  3. Build links with kahunacart_url() and the whole path, post the buy form to kahunacart_url('/cart'), and give a GET search or filter form the hidden currency field. See Links on a store that sells in more than one currency.
  4. 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 overrides kahunacart-checkout.html.twig needs this block in the payment fieldset, directly after the providers_excluded_physical block 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.

  5. 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.
  6. 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.
  7. Include the cart currency notice. Shop-page overrides should include partials/kahunacart-currency-notice.html.twig below 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. See kahunacart_currency_notice().
  8. Bring overrides up to date. Run bin/plugin kahunacart templates:drift and 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-switcher and kahunacart-currency-notice are new; kahunacart-unavailable-in-currency is also new in 1.4.0.

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.

TWIG
<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/' ~ slug any more, and don't add ?variant=2 or #reviews to what kahunacart_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 is true to carry the currency whatever the path, false to 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 handed kahunacart_currency (the code, absent for a visitor in the base currency) for exactly this, and the filter panel's filters.currency is 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_canonical and the SEO partial already build the canonical address.

  • A shop at the site root has an empty kahunacart_base. With route: '/' on a domain root, kahunacart_base is empty so {{ kahunacart_base }}/cart still gives /cart. Link to the front door itself with kahunacart_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.

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:

TWIG
<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:

TWIG
{% 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_currency and 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: false on a row of options_payload, which is the same fact: that variant has no price, available is false, and price, price_html and compare_html are null. The text not_priced ("Not available in EUR") is in the payload's text on such a store. The shipped kahunacart-options.js treats 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 its title and, in a dropdown, in the option's text.
  • A theme that decides "on sale" itself by comparing compare_price_minor with price_minor is 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. Use kahunacart_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.

TWIG
<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:

TWIG
<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:

TWIG
{% 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:

YAML
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:

TWIG
{% 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:

HTML
<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:

HTML
<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:

YAML
# 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.