# GS Loom UI components

## GS Loom UI: one behavior, theme-owned appearance

GS Loom UI v1 is the shared component layer for storefront themes. It is separate from the admin/customer panel library. A theme uses ordinary HTML with `data-gs-ui`; GS Loom adds common CSS and interactive behavior. The theme supplies colors, radii and any custom variants. Existing HTML, animations, schemas and plugin permissions continue to work.

This page explains components through source code and configuration examples. Theme packages also contain `snippets/ui-demo.html` for optional testing on a private development page.

## Contract and variants

```html
<button type="button" data-gs-ui="button" data-variant="primary" data-size="md">
  Get started
</button>
<a href="/contact" data-gs-ui="button" data-variant="outline">Contact</a>
```

Use a real button for an action and a real link for navigation. Variants change appearance, not semantics. `data-size` changes size independently of `data-variant`. Existing theme classes can coexist with these attributes. The contract is additive: migration does not require replacing every existing element or removing animation hooks.

| Component | HTML contract | Built-in variants |
| --- | --- | --- |
| Button | `button` or `a` with `data-gs-ui="button"` | `primary` (default), `secondary`, `outline`, `ghost`, `link`, `danger`; sizes `sm`, `md` (default), `lg` |
| Card | `article` or `div` with `data-gs-ui="card"` | `surface` (default), `outline`, `elevated`, `ghost` |
| Badge | `span` with `data-gs-ui="badge"` | `primary` (default), `secondary`, `outline` |
| Field | wrapper with `data-gs-ui="field"` | shared 8 px label/control spacing |
| Input / Textarea | `input data-gs-ui="input"` / `textarea data-gs-ui="textarea"` | `outline` (default), `filled`, `underline` |
| Select | `div data-gs-ui="select"` containing a single native `select` | `outline` (default), `filled` |
| Dropdown | `details data-gs-ui="dropdown"`, `summary`, content wrapper | `secondary` (default), `outline`, `primary` |
| Tabs | `div data-gs-ui="tabs"`, tab list, buttons and panels | `line` (default), `pills`; horizontal or vertical |
| Accordion | `details data-gs-ui="accordion"` with `summary` | `surface` (default), `outline`, `ghost` |
| Modal | `dialog data-gs-ui="modal"` | `default`, `surface`; sizes `md` (default), `lg` |

A custom variant such as `brand` is allowed. It does not gain behavior automatically: define its appearance in the theme CSS. A variant meaningful for a button is not automatically meaningful for an input or modal.

## Theme tokens and custom variants

Put tokens in `assets/ui.css`. GS Loom loads shared styles before theme assets. In a Loom theme the following rule is scoped to that theme; document themes receive the same CSS in their isolated document.

```css
:scope {
  --gs-ui-accent: #e59d02;
  --gs-ui-on-accent: #161006;
  --gs-ui-text: #f8f8f8;
  --gs-ui-background: #040000;
  --gs-ui-surface: #191715;
  --gs-ui-border: #ffffff29;
  --gs-ui-focus: #e59d02;
  --gs-ui-radius: 20px;
  --gs-ui-button-radius: 50px;
  --gs-ui-shadow: 0 16px 40px #0003;
}
[data-gs-ui="button"][data-variant="brand"] {
  background: #5347ce;
  color: #ffffff;
  border-color: #5347ce;
}
```

You may also override `--gs-ui-danger` and `--gs-ui-on-danger`. Bind tokens to your theme settings when colors are editable. Choose foreground and background together and test contrast. Skeleton uses yellow on dark surfaces, Paylio dark buttons on a light surface, and Victorie orange buttons with light/dark surfaces. Imported themes inherit the platform's base components; their token file is part of the exported theme package.

## Editable button variants

Declare a `select` setting in the block schema, then bind it to `data-variant`:

```json
{
  "type": "select",
  "id": "button_variant",
  "label": "Button variant",
  "default": "primary",
  "options": [
    {"value":"primary","label":"Primary"},
    {"value":"secondary","label":"Secondary"},
    {"value":"outline","label":"Outline"}
  ]
}
```

```html
<a data-gs-ui="button" data-variant="[[ block.settings.button_variant ]]"
   href="[[ safe_url(block.settings.url) ]]"
   data-text-field="[[ block.settings_path ]].label"
   gs-text="block.settings.label"></a>
```

Skeleton's existing Card block exposes this setting. The same pattern works for card or other applicable variants. A schema `select` configures the editor; it is different from a storefront Select component. The shared UI does not automatically add a block to the builder: define a block schema, preset and parent targeting as usual.

## Inputs and Select

```html
<div data-gs-ui="field">
  <label for="contact-topic">Topic</label>
  <div data-gs-ui="select" data-variant="filled">
    <select id="contact-topic" name="topic" required>
      <option value="">Choose a topic</option>
      <option value="sales">Sales</option>
      <option value="support">Support</option>
      <option value="other" disabled>Unavailable</option>
    </select>
  </div>
</div>
```

After initialization the platform presents a custom combobox/listbox. The native select remains the form value and validation source. Arrow keys move between enabled options, Home/End reach the first/last option, typing finds a label, Enter/Space select, Escape cancels and Tab closes the list. Clicking outside closes it. `change` and `input` events and form reset retain standard behavior. Without JavaScript the native select remains usable. Multiple selection is not enhanced in v1.

Keep a real label associated with the native select's `id`; the runtime transfers its accessible name to the trigger. Use unique IDs per instance, for example derived from `block.id`, rather than copying example IDs into repeated blocks. Put an Input or Textarea in the same Field wrapper, with a matching label. Use `disabled`, `required` and `aria-invalid` as appropriate. UI primitives provide no submission endpoint: use the existing Forms plugin for sending data and server validation. The library does not replace plugin-owned field markup or bypass access checks.

## Navigation dropdown and Languages

```html
<details data-gs-ui="dropdown" data-variant="outline">
  <summary>Explore</summary>
  <nav data-gs-dropdown-content aria-label="Explore">
    <a href="/about">About</a>
    <a href="/contact">Contact</a>
  </nav>
</details>
```

Enter/Space opens the native disclosure. Tab moves through links. Escape closes and returns focus to the summary; leaving the control or clicking outside also closes it. This is a disclosure with links, not an ARIA menu. Do not add menu roles without the corresponding keyboard behavior.

`<gs-languages variant="dropdown"></gs-languages>` uses the same dropdown behavior and retains the language plugin's navigation/editor contract. Each Languages-capable theme must supply its own switcher appearance. Skeleton's `assets/languages.css` extends its shared UI tokens. The plain link variant remains available. See [Languages](/en/locales/).

## Tabs and Accordion

```html
<div data-gs-ui="tabs" data-variant="pills" data-value="overview">
  <div data-gs-tab-list aria-label="Product information">
    <button data-gs-tab="overview">Overview</button>
    <button data-gs-tab="details">Details</button>
  </div>
  <section data-gs-panel="overview">Overview content</section>
  <section data-gs-panel="details">Detailed content</section>
</div>
<details data-gs-ui="accordion" data-variant="outline">
  <summary>How does this work?</summary>
  <p>Your answer.</p>
</details>
```

Tab keys match panel keys inside their component. The runtime generates IDs, roles and relationships, hides inactive panels and manages roving keyboard focus. Arrow keys, Home and End select enabled tabs automatically; `data-orientation="vertical"` uses up/down arrows. Tab and panel definitions should remain stable during a mounted instance; remount when replacing the whole set. Before JavaScript initializes, all panel content remains readable.

Accordion uses native `details` behavior. Add `open` for an initially expanded item. Use the same `name` on related details when you want an exclusive group; choose a unique group name per block instance.

## Modal

```html
<button type="button" data-gs-ui="button" data-gs-open="shipping-dialog">Shipping</button>
<dialog data-gs-ui="modal" data-size="lg" id="shipping-dialog" aria-labelledby="shipping-title">
  <h2 id="shipping-title">Shipping information</h2>
  <p>Content shown on demand.</p>
  <button type="button" data-gs-ui="button" data-variant="secondary" data-gs-close autofocus>Close</button>
</dialog>
```

The runtime opens native `dialog` with `showModal()`, providing modal focus containment, Escape and focus return. The close control and a click outside the dialog rectangle close it. Give it an accessible title and an obvious close button. Targets are resolved within the current theme wrapper; repeated blocks need unique target IDs. A modal is not a confirmation or permission system and does not submit anything by itself.

## Focus, active and disabled states

Focus is visible without changing the component's shape. `:active` describes a pressed control; persistent selection uses `aria-selected`, `aria-current`, or `details[open]`. Native disabled buttons do not activate. For disabled links use `aria-disabled="true"` and `tabindex="-1"`; the shared runtime prevents activation. Do not use a disabled-looking class as access control. See [focus rules](/en/inputs/) for composite search fields and dropdowns.

## Migration and verification

The three bundled themes now include UI tokens and a reusable UI demo. Existing Skeleton/Paylio button and card markup and Victorie CTA buttons opt into the shared contract while retaining their original classes and animation hooks. Specialized navigation, sliders and video players keep their existing behavior. These are not silently replaced with generic controls.

Test variants and themes, keyboard navigation, labels, validation, disabled options, form reset, modal Escape/focus return, two instances on one page, mobile overflow and reduced motion. Keep CSS and UI code changes in the theme/runtime, not in saved customer content. Rebuild with `npm run loom:build`; the developer portal download is rebuilt with `npm run build:developers`. Do not copy platform runtime files into every theme: GS Loom supplies them in rendering and preview.

## Equal height and grow

Use `data-gs-equal-height` on a flex row or grid. Each direct child becomes a flex wrapper; put `data-gs-grow` on the card or button inside it. Items stretch to the tallest content in their row, including after translation or live editing. The theme still defines columns, gaps and responsive breakpoints. No fixed heights or JavaScript measurements are needed.

```html
<div data-gs-equal-height class="feature-grid">
  <div>
    <article data-gs-ui="card" data-gs-grow data-gs-stack>
      <h3>Feature</h3>
      <p>Short or long translated content.</p>
      <div data-gs-footer>
        <a data-gs-ui="button" href="/details">Details</a>
      </div>
    </article>
  </div>
  <!-- Repeat the wrapper for each card. -->
</div>
```

```css
.feature-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr));
  gap: 24px;
}
```

`data-gs-stack` creates a vertical flex layout; `data-gs-footer` pushes actions to the bottom. Keep normal spacing above actions (for example a gap or footer padding). `data-gs-grow` also works independently inside flex layouts. On mobile, separate rows retain natural heights. For equal heights across ALL grid rows, explicitly add `grid-auto-rows: 1fr` to the grid. These attributes work with any component variant and do not replace its colors, borders or animation.

## Header and menu editing

GS Loom uses `data-gs-header="header"` for headers, `data-gs-logo` for logos and `data-gs-menu="header"` or `"footer"` for navigation. `<gs-menu location="header">` emits this marker automatically and reads the site menu. Preserve semantic `header`, `nav` and links. Themes own appearance, breakpoints and dropdowns.

For a schema-owned header, add `data-gs-header-group="header"`. Clicking the header, logo or menu item opens that group in the builder; edits persist in `loom.groups.header` (the legacy data property name used by GS Loom). Skeleton uses this variant to retain its nested navigation and translations. Without this attribute, logos open the shared logo editor and menus the site navigation manager. Do not mix both data sources for the same items. Public links behave normally.

## Dropdowns, nested navigation and megamenus

GS Loom separates the menu data from its appearance. The builder stores a tree of items; `<gs-menu>` renders that tree, and the theme CSS controls its appearance. The examples below are source code, not interactive previews.

### 1. Place the menu in the header

```html
<gs-menu location="header" label="Main navigation"></gs-menu>
```

Put this markup in the theme header section or snippet. `location="header"` selects the site menu assigned to that location; `label` is its accessible name. Do not paste the JSON below into this HTML: it illustrates items stored in the site menu. In the builder, use **Navigation → Header**, expand an item and choose **Add child item**.

Editing the header menu enables `use_site_menu`. Paylio uses the shared header; Skeleton and Victorie Vending keep their original demo navigation until this setting is enabled. Use one data source for each menu, rather than maintaining both hardcoded links and site menu items.

### 2. Dropdown with children and grandchildren

```json
{
  "id": "services",
  "label": "Services",
  "href": "/services",
  "menu_layout": "dropdown",
  "children": [
    {
      "id": "websites",
      "label": "Websites",
      "href": "/websites",
      "icon": "globe",
      "children": [
        {
          "id": "design",
          "label": "Design",
          "href": "/design",
          "icon": "star"
        },
        {
          "id": "development",
          "label": "Development",
          "href": "/development",
          "icon": "code"
        }
      ]
    }
  ]
}
```

`Services → Websites → Design / Development` creates three levels. Each object in `children` follows the same contract, so a child can contain its own children. Without children, an item renders as a link. Omitting `menu_layout` uses `dropdown`. This object belongs in the header menu’s `items` array.

| Field | Meaning |
| --- | --- |
| `id` | Stable item identifier; keep it when renaming or reordering. |
| `label` | Visible link or submenu trigger text. |
| `href` | Destination URL. Use `#` for a parent that only opens its submenu. |
| `children` | Array of child items; omit it for a leaf link. |
| `menu_layout` | `dropdown` for a list, `mega` for columns of immediate children. |
| `icon` | Library icon key or `image:<URL>`; omit for no icon. |

### 3. Megamenu with icons

```json
{
  "id": "explore",
  "label": "Explore",
  "href": "#",
  "menu_layout": "mega",
  "children": [
    {
      "id": "products",
      "label": "Products",
      "href": "/products",
      "icon": "star",
      "children": [
        {
          "id": "skeleton",
          "label": "Skeleton",
          "href": "/products/skeleton"
        },
        {
          "id": "paylio",
          "label": "Paylio",
          "href": "/products/paylio"
        }
      ]
    },
    {
      "id": "support",
      "label": "Support",
      "href": "/support",
      "icon": "image:/media/support.svg",
      "children": [
        {
          "id": "contact",
          "label": "Contact",
          "href": "/contact"
        },
        {
          "id": "docs",
          "label": "Documentation",
          "href": "/docs"
        }
      ]
    }
  ]
}
```

Set `menu_layout: "mega"` on the parent that opens the wide panel. Here Products and Support form two columns; their `children` contain the links below each heading. `star` selects a library icon. `image:/media/support.svg` illustrates an uploaded image: replace it with the real URL returned by the file manager. The property accepts an image URL, not raw SVG markup. You can change either column’s children without changing the renderer.

### 4. Match the theme appearance

```css
[data-gs-menu="header"] {
  --gs-menu-background: #17201c;
  --gs-menu-color: #ffffff;
  --gs-menu-border: #ffffff33;
  --gs-menu-radius: 16px;
  --gs-menu-gap: 24px;
}
```

Add these rules to the theme stylesheet. The selector limits the tokens to the header menu. Background, text, border, corner radius and spacing can differ between themes while retaining shared behavior. Keep focus indicators visible and ensure panels fit the viewport.

### 5. Behavior, translations and limits

Click or Enter/Space opens a submenu; Escape closes the current level and returns focus to its trigger. If a parent has a real URL, the renderer adds that link at the start of its panel, so opening the panel does not navigate away. On small screens nested panels expand vertically. A megamenu is not a separate page or plugin.

With Languages enabled, edit labels per language in the builder. Keep item IDs stable so translations remain associated with the correct items; icons, URLs and layout are language-independent. Limits are **5 levels including the root**, **30 siblings per branch** and **100 items per menu**. Check long translations, keyboard operation and mobile layouts. See [Menus](/en/menus/) for the site navigation data contract and [Languages](/en/locales/) for localization.


### Visual menu editing in the builder

Click the Skeleton header to open the menu tree. Only existing items are listed; expand a parent to see its children. Select an item and click its name to edit it directly. Its link, icon and submenu layout are shown only for that selection. Add, remove and reorder items without editing schema fields. The Appearance tab controls text/background/active colors, font, font size and spacing. Apply writes a draft and preserves localized labels; publication remains a separate action.
