# Write components with Smootify elements

> Build .astro components with Smootify's elements and attributes, and the plain-HTML markup that replaces dropdowns, sliders, tabs and form messages.

Status: Ready (Smootify 2.0)  
URL: https://www.smootify.io/docs/astro/build/components  
Updated: 2026-10-21

A Smootify component is an `.astro` file that writes Smootify’s elements and attributes as plain HTML. Smootify reads the HTML at load, so the same elements and attributes work as on any Smootify site. This page covers the rules of the markup, the parts that need a plain-HTML form, and how to style them.

> [!NOTE]
> The elements, attributes and their values are the same everywhere: see [Build the cart](https://www.smootify.io/docs/astro/build/cart) and the other Build pages for each one. This page is about how they are written in Astro.

## 1. Elements and attributes

A Smootify element is a tag, such as `smootify-product`, `smootify-cart` or `smootify-add-to-cart`. Its parts are attributes on ordinary elements, such as `product="title"`, `cart="subtotal"` or `data-action="remove"`. Write them in the markup:

```astro
---
const { p } = Astro.props;
---
<div class="sm-dyn-item">
  <smootify-product data-id={p.shopifyId}>
    <a href={`/products/${p.id}/`}>
      <img src={p.image} alt={p.imageAlt} width={p.imageWidth} height={p.imageHeight} />
      <h3 product="title">{p.name}</h3>
      <div product="price">{p.priceFormatted}</div>
    </a>
  </smootify-product>
</div>
```

- Every card of a product list has the class `sm-dyn-item`, the boundary of the card, and inside it a `smootify-product` with `data-id` set to the product’s `shopifyId`. The build reads that id: see [First load](https://www.smootify.io/docs/astro/start/first-load).
- Render what the entry knows, such as the title, the picture and the price, in the markup. They are also the HTML that search engines read.
- Do not hand-write what Smootify draws from the variant: prices after a variant change, stock messages, selected option labels.
- `astro check` knows the attributes: the integration adds their types. If it rejects an attribute that exists, do not cast it. The types are missing it: report it.
- Conditions that depend on the page’s data are Astro expressions (`{p.compareAtPrice > p.price && …}`). Conditions that depend on the visitor or on stock stay attributes (`condition="out-of-stock"`, `customer-condition`).
- A tag that Smootify’s reference calls a marker, such as `search-with-query`, is an element, not an attribute.

## 2. Props

Props are the component’s `Props`, in camelCase, with a default. A variant is a `variant` prop that adds the class `is-<variant>`. A part the caller fills is a `<slot />`.

```astro
---
interface Props {
  /** Shows the wishlist heart */
  showWishlist?: boolean;
  variant?: "default" | "compact";
}
const { showWishlist = true, variant = "default" } = Astro.props;
---
<div class:list={["sm-card", `is-${variant}`]}>
  <slot />
  {showWishlist && <wishlist-toggle></wishlist-toggle>}
</div>
```

## 3. Plain HTML instead of native widgets

Smootify’s runtime reads standard HTML. Use these forms.

| You need | Write |
|---|---|
| A dropdown | A `[popover]` with its trigger `button popovertarget`. The Smootify tags are `sort-popover`, `filter-popover`, `variant-popover`, `addon-popover` and `configurator-popover`. |
| A hidden part | The `hidden` attribute. The stylesheet hides `[hidden]` with `display: none !important`. |
| A form’s success and error message | `[form-done]` and `[form-fail]`, each with `hidden`, next to the `<form>`. |
| A slider | `[slider-track]` (its children are the slides), `[slider-prev]`, `[slider-next]` and `[slider-dots]`. |
| Tabs | WAI-ARIA tabs keyed by `data-tab`. |
| A lightbox | `a[lightbox-item]` and a viewer component. |
| A checkbox or radio that Smootify repeats | `<label><input type="checkbox"><span form-label>Label</span></label>`. |
| A language switcher | The `[locales]` element, with an `a[hreflang]` link for each locale. |
| A menu or a drawer | A `[popover]` or a `<dialog>` with `data-overflow-body`. |
| A card boundary | `.sm-dyn-item` on the card. |

A few rules go with them:

- **Popovers.** The open and close transitions are CSS: `@starting-style` and `transition-behavior: allow-discrete`. The current option gets `aria-current="true"` and the trigger `aria-expanded`. A popover inside a template that Smootify repeats gets its own `id` in each copy; on cards rendered on the server, give each popover its own `id`. Inside `smootify-cart`, keep popovers out of the lines (see [Popovers](https://www.smootify.io/docs/astro/build/popovers)).
- **Sliders.** Smootify runs `[slider-prev]`, `[slider-next]` and `[slider-dots]` only in media sliders (`gallery="slider"` and product videos) and in Magic Box. A slider of product cards or of other content uses the site’s own slider script.
- **`hidden`.** Smootify marks what it hides with its own attribute. Never write that one, and never write `hidden` on something Smootify should show, such as a filter widget or the load more button. Hide those with CSS on the host’s state class.
- **Form messages.** A form that Smootify does not handle, such as a contact form, shows its own `[form-done]` and `[form-fail]` from the response of its `fetch`. A cart’s `[form-done]` is its empty state.
- **Locales.** On a site with `locales`, the switcher is a `[locales]` element of real links, one for each page in the other language or market, built with `localeSwitcherLinks`. Do not use `country-switcher` there: it changes the country without changing the page, so the page and the visitor’s choice would disagree.
- **Search and discovery.** Render the real product cards on the server. Smootify takes the first `.sm-dyn-item` as the template and rewrites the list from the results. Render only `limit` cards, in the collection’s order.

## 4. After Smootify renders

When Smootify renders or clones something, it fires `smootify:render` on `document`, at most once every 400 ms. `event.detail.nodes` is always an empty array, so a script that wires its own behaviour, such as an animation on product cards, listens to the event, queries the nodes it has not wired yet and marks them:

```astro
<script>
  document.addEventListener("smootify:render", () => {
    for (const node of document.querySelectorAll(".sm-card:not([data-wired])")) {
      node.setAttribute("data-wired", "");
      // wire the node
    }
  });
</script>
```

Make the binding safe to run again: the event fires at every render. Sliders, tabs and popovers need no listener.

## 5. Styles

- A component’s styles go in its scoped `<style>`. Smootify clones templates, and the clone keeps the component’s scope attribute, so scoped rules, including state classes such as `.empty-count`, still match.
- Use `:global()` for HTML that Smootify writes from data, which has no scope attribute: rich text, `set:html`, and classes that Smootify sets on a host (`:global(.host.inited) .part`).
- A class that many components write, such as a button or a text style, lives in `global.css`.
- Smootify’s custom tags are inline by default: give them a `display` when they carry layout.
- Where Smootify only hides what it must, the look of a state is yours: write it from the host’s state classes, such as `is-open` or `is-empty`.
- A template `img` with no data has no `src`. Leave the attribute out, and hide the image with `img:not([src]) { visibility: hidden }`.
- Popovers keep two variables: `--smootify-closed-display` and `--smootify-backdrop-color`.

## 6. Examples

The example project for Smootify for Astro has components for each part of a store, in folders by what they do:

- `product/` has the add to cart form, the card, the price, the gallery, the quantity, the stock messages and the wishlist button;
- `variant/` has colour swatches, pill swatches and the variant popover;
- `cart/` has the cart drawer and the line template, with the empty state as `[form-done]`;
- `collection/` and `filters/` have the sort popover, the filters and the active filters of search and discovery;
- `navbar/`, `layout/` and `consent/` have the navbar with predictive search, the footer and the cookie banner;
- `markets/` has the country switcher, and `server/` has a newsletter form with `[form-done]` and `[form-fail]`.

Each file starts with a comment that says what it builds and where it goes.

## Before you publish

> [!STUCK]
> - **A dropdown never opens.** A `*-dropdown` tag looks for a native dropdown that does not exist here. Use the `*-popover` tag with a `[popover]` and a `button popovertarget`.
> - **A part stays hidden.** It has `hidden` that Smootify does not remove. Remove it, and hide with CSS on the host's class instead.
> - **A script works on the first cards only.** It runs once at load. Bind again on `smootify:render`.
> - **A cloned card loses its style.** The rule is in a `<style is:global>` that depends on markup, or targets HTML that Smootify wrote. Keep scoped rules for templates and use `:global()` only for written HTML.
> - **The list reshuffles at load.** The server cards are not in the collection's own order, or there are more of them than `limit`.
> - **Prices do not match after load.** A value is written by hand. Render the title and image, and let Smootify draw prices and states.
> - **The language switcher changes the price but not the page.** It is a `country-switcher`. On a site with `locales`, use `[locales]` with links.
