Skip to content

Creating Components ​

A component is a Vue file plus a definition in etc/components/<Template>.graphqls. The definition tells the editor how to offer the component (its name, icon, settings, slots and where it can go) and describes the props the file receives. The file draws it on the storefront. A component from your module works exactly like a built-in one.

This page builds a small Highlight component in a module called Vendor_Module: a boxed title and text, with a slot for buttons underneath.

Claude Code

The Lynx Claude Code skills can create a component, its provider and its settings for you, following this page. See Working with Claude Code.

1. The module ​

Your module depends on OOCS_LynxComponent, which holds the component registry and the provider contract.

etc/module.xml:

xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
    <module name="Vendor_Module">
        <sequence>
            <module name="OOCS_LynxComponent"/>
            <!-- Only if you use its MediaUrl / StoreLink helpers, see Setting Types -->
            <module name="OOCS_LynxThemeComponent"/>
        </sequence>
    </module>
</config>

In composer.json, require oocscommerce/module-lynx-component (and oocscommerce/module-lynx-theme-component if you listed it above).

Frontend files go in a fixed folder:

app/code/Vendor/Module/
├── etc/
│   ├── module.xml
│   └── components/
│       └── Highlight.graphqls                ← component definition and props
├── Provider/
│   └── Highlight.php                         ← optional, see Component Providers
└── view/frontend/lynx/
    └── vue/src/Components/Highlight.vue      ← the component file

The path under src/ is up to you (Components/Promo/Highlight.vue works too), but the view/frontend/lynx/vue/src part is fixed.

2. The component file ​

Settings arrive as props, under the field names of the definition, with their defaults already filled in. Children placed in a slot arrive as Vue named slots.

The props are typed by Lynx.<Type>, a global type Lynx generates from the definition (see The props type). Do not write the props by hand.

vue
<!-- Highlight.vue -->
<script setup lang="ts">
const props = defineProps<Lynx.VendorHighlight>()

// Another module can add options to the select, so map them with a fallback
const TONE: Record<string, string> = {
    soft: 'bg-base-200',
    outline: 'border-(length:--border) border-base-300',
}
</script>

<template>
    <div class="w-full rounded-box p-6" :class="TONE[props.tone] ?? TONE.soft">
        <h2 v-if="title" class="text-xl font-semibold text-base-content">{{ title }}</h2>
        <p v-if="text" class="mt-2 text-base-content/70">{{ text }}</p>
        <div v-if="$slots.actions" class="mt-4 flex flex-wrap gap-3">
            <slot name="actions" />
        </div>
    </div>
</template>

Lynx.*, NodeSlots and LayoutItem are global types; use them without importing.

Rules for the component file ​

  • One root element. In the editor preview, Vue puts the attributes that let the editor find and highlight the node on the component's root, and the align setting arrives the same way. A component with several root nodes (a div next to a Teleport, for example) should wrap everything in one <div>.
  • Do not give settings defaults in the component. The default in the definition is the only default; the server fills it in before your component sees the props, so settings are required in Lynx.<Type>. Defaults in the component (withDefaults, destructuring defaults) are for props your provider adds, such as lists or labels.
  • Map select values with a fallback. A select is typed as the union of its options, but another module can add options to it, so a lookup table is a Record<string, string> with a fallback (TONE[tone] ?? TONE.soft).
  • An empty slot is not passed at all. Test $slots.name to leave out the wrapper around an empty slot.
  • Take visible text from the server. Text the shopper sees (button labels, placeholders, aria-label, error messages) is not hardcoded in the component. Your provider translates it and sends it in a labels prop, so the store's language packs apply. Setting values that the merchant types are content and come through as settings. See Component Providers.
  • Style with theme tokens and l- controls so the component follows each design's settings: colors, corners, border width, control height and widths. The table in Which design setting reaches which class lists the classes per setting.
  • Keep it SSR-safe. Do not touch window or document during setup or render; use onMounted. See Server-side Rendering.

Align ​

A block that declares an align setting (None / Wide / Full) can reach past the content width inside a constrained Group. You do not read align in your component: Lynx takes it out of the props and applies it to the node, as a data-align attribute on your root element. Declare an align setting of the shared Align type, marked @optional because the component never receives it (see the example below), and it works.

Because align is taken for this, name other alignment settings differently, as the built-in components do: text_align for text, justify for the position of an image or a button, vertical_align for vertical alignment.

Headless components ​

A component that only runs a script (a tracker, an integration) and draws nothing must still render one consistent node on the server and in the browser. Leave out <template> and declare a render function that returns null; an empty <template> breaks hydration of the nodes after it.

vue
<!-- Tracker.vue -->
<script setup lang="ts">
import { onMounted } from 'vue'

defineOptions({ render: () => null })

onMounted(() => {
    // load the script, subscribe to events…
})
</script>

Put headless components in the backstage zone.

3. Register the component ​

Add etc/components/Highlight.graphqls to your module. The file is written in GraphQL's schema language (SDL): Lynx reads every etc/components/*.graphqls of the enabled modules, in module <sequence> order, and merges them into one set of definitions. Lynx has no GraphQL API; the format is only a compact way to write types. One file per template, named after it, is the convention.

graphql
enum VendorHighlightTone {
  SOFT @label(text: "Soft")
  OUTLINE @label(text: "Outline")
}

"A boxed title and text with room for buttons"
type VendorHighlight @component(
  code: "vendor_highlight"
  template: "Vendor_Module::Components/Highlight"
  provider: "Vendor\\Module\\Provider\\Highlight"
  label: "Highlight"
  group: "text"
  icon: "ph-star"
) {
  "How far this block reaches inside a constrained Group"
  align: Align! @setting(label: "Align", default: "none") @optional
  title: LocalizedText! @setting(label: "Title", default: "Free shipping over $50")
  text: LocalizedTextarea! @setting(label: "Text", default: "")
  tone: VendorHighlightTone! @setting(label: "Style", default: "soft")
  actions: Slot @slot(label: "Buttons", allow: ["text"])
}

The title and text settings are translatable (Localized…), so this component needs a provider that picks the text for the store view. That provider is the example on Component Providers.

A type's fields are of three kinds:

FieldHow it is writtenWhat it is
Setting@setting(…)A field in the settings panel; its type picks the control. See Setting Types.
Slottype Slot with @slot(…)A named area that holds child components. See slots.
Provider datano directiveA prop your provider adds, such as a list of products or labels. See Provider data.

Type and enum names are global across all modules, and become Lynx.<Name> in TypeScript: prefix yours with your vendor. Shared enums such as Align, Justify and TextAlign are declared by OOCS_LynxComponent (etc/components/enums.graphqls); reuse them.

@component arguments ​

ArgumentRequiredWhat it does
codeyesThe component's registered name (vendor_highlight). It is stored in every layout that uses the component, so keep it stable and prefix it with your vendor.
templateyesVendor_Module::<path under src without extension>. It resolves to the .vue file, or the theme's override of it.
labelyesName in the component picker and the outline.
providernoA class that adds server data to the props, without \Proxy: it is created only on pages that contain the component. See Component Providers.
iconnoA Phosphor Icons class name, regular weight, such as ph-image, ph-table, ph-tag-simple. The editor ships the icon font. Without it a generic icon is used.
groupnoHeading in the picker and the key that slots filter on. See groups. Without it the component is in general.
zonesnoZones the component can be added to. See zones.
availableOnnoPages the component can be added to. See pages.
hiddennotrue keeps the component out of the picker. Use it for a node that only your code places in a layout; its provider still runs.

The string above the type is its description, shown when hovering the component in the picker and behind the ? icon of its settings panel. A string above a field describes that setting.

Several registrations can share one template (each with its own code, label and provider). @inherit(from: "OtherType", except: ["field"]) copies the fields of another type first, so they need not be repeated.

Provider data ​

Declare every key your provider adds, as a field without a directive. The server sends only declared fields: a key that is not in the definition is dropped before the props reach the browser, nested types and lists included (StringMap and Json values are kept as they are). In developer mode each dropped key is logged to var/log/system.log as Lynx props: <code>.<path> is not declared in etc/components/*.graphqls, dropped., so a field you forgot shows up there.

graphql
type VendorDeals @component(code: "vendor_deals", template: "Vendor_Module::Components/Deals", provider: "Vendor\\Module\\Provider\\Deals", label: "Deals") {
  title: LocalizedText! @setting(label: "Title", default: "")
  "Every string the component shows, translated by the provider"
  labels: StringMap! @optional
  products: [VendorDealsProduct!]! @optional
}

type VendorDealsProduct {
  id: Int!
  name: String!
  "Null when the product has no special price"
  special_price: Float
}
WrittenMeaningTypeScript
String!Never nullstring
FloatMay be nullnumber | null
@optionalMay be missing, for example before a deferred provider's data arrives?
[X!]!A list (lists nest: [[X!]!]!)X[]
StringMapAn object of strings, such as labelsRecord<string, string>
JsonAny value, not checked below this pointany

The base types are String, Int, Float, Boolean, enums and the types you declare.

The props type ​

bin/magento lynx:static:deploy, lynx:theme:setup and lynx:static:test write lynx/dist/props.d.ts in each Lynx theme: a global Lynx namespace with one interface per component, one per data type and one string union per enum, named as in the definitions. Settings are required (the server fills in defaults), slots are not props, and every component also gets an optional _key (see Deferred and optional data).

ts
const props = defineProps<Lynx.VendorDeals>()
function price(product: Lynx.VendorDealsProduct) { … }

A template shared by several registrations takes the union (defineProps<Lynx.ProductListing | Lynx.SearchResults>()); fields only one of them declares are optional in the others. After changing a definition, run lynx:static:deploy: npm run build alone does not rewrite the types.

Changing another module's component ​

A module can change a component, a data type or an enum declared by another one with extend in its own etc/components/*.graphqls. List the declaring module in your <sequence>: files are merged in that order.

graphql
extend type VendorHighlight @component(label: "Notice", icon: "ph-info") {
  tone: VendorHighlightTone! @setting(label: "Style", default: "outline") @replace   # change a field
  text: LocalizedTextarea @remove                                                   # drop a field
  badge: String @optional                                                           # add a field
}
extend type VendorDealsProduct { brand: String }                                     # add to a data type
extend enum VendorHighlightTone { DARK @label(text: "Dark") }                        # add an option
extend type VendorDeals @remove                                                      # unregister a component
  • Arguments of @component in an extend replace those of the original one by one.
  • Declaring a field that already exists without @replace is an error, as is @replace or @remove on a field that does not exist, a duplicate type name or a duplicate code. Errors name the file.
  • Data for a field you add comes from an afterResolve plugin on the component's provider.
  • To add markup inside the component (under each list item, say) or run code before it sends something, use its containers and events: Adding to Built-in Components.

Editor rules ​

Zones, pages, groups and slot limits are rules of the editor: they decide what can be added or dropped where. The MCP tool pagebuilder_save_layout checks zones, pages, groups, declared settings and declared slots too, but not slot limits. A layout that already contains a node renders it wherever that layout is used.

Zones ​

ZoneMeaning
headerThe shared header zone
contentThe page's own content
footerThe shared footer zone
backstageComponents that render nothing (trackers, scripts). Present on every page, the checkout included, and mounted inside a hidden element. Only components that list backstage can be added there.
(omitted)Header, content and footer, but not backstage
graphql
type VendorHighlight @component(…, zones: ["content"])

The header and footer are one layout shared by every page, so a component placed there appears on every page whatever its availableOn says.

Pages ​

availableOn lists the pages, by full action name, where the component can be added. A trailing * matches by prefix.

graphql
type VendorHighlight @component(…, availableOn: ["catalog_product_view*", "checkout_cart_index"])
ValuePages
cms_index_indexHome page
catalog_category_viewCategory pages
catalog_product_view*Every product page (each product type has its own page: catalog_product_view_type_simple, …_configurable…)
catalog_product_view_type_simpleSimple product pages only
checkout_cart_indexCart
checkout_index_indexCheckout
customer_account_loginSign-in page
(omitted)Every page

For a product type Lynx does not ship a page for, see Product Types.

Groups ​

group is a snake_case key. The picker shows it as a heading (product_list becomes "Product List"), with General last. There is no list to register: a new key creates a new group.

Reusing a built-in group puts your component next to similar ones, and lets it into the built-in slots that accept that group. Slots on the product, category, cart and checkout components also accept general, so a component without a group can be placed there.

GroupBuilt-in componentsAccepted by slots of
layoutGroup, Row, Column, Tabs, Spacer, DividerProduct, Product Media, Product Detail, Product Listing, Checkout, Checkout Cart
tabTabTabs (only this group)
textHeading, Paragraph, Button, Link ListProduct, Product Media, Product Detail, Product Listing, Checkout, Checkout Cart
mediaImage, Video, Banner, CarouselProduct, Product Media, Product Detail, Product Listing, Checkout, Checkout Cart
formContact Form, Newsletter FormProduct, Product Media, Product Detail, Product Listing, Checkout, Checkout Cart
header_footerHeader, Announcement Bar, Store Switcher, Currency Switcher, Social Links
utilityCookie Consent, Back To Top, Custom Script, Google Analytics 4
product_listProduct Carousel, Related / Up-sell / Cross-sell Products, Product Listing, Compare ProductsProduct, Product Media, Product Detail, Product Listing, Checkout, Checkout Cart
searchSearch Results, Advanced Search Form, Advanced Search Results, Popular Search Terms
product_pageProduct, Product Media, Product Detail (every type), Description, Specifications, ReviewsProduct: Images, Details
product_attributeProduct Attribute, Product Image Attribute, Price Alert, Stock AlertProduct: Images, Details; Product Media: Below Gallery; Product Detail: Below Price, Below Add to Cart
category_attributeCategory Attribute, Category Image AttributeProduct Listing: Below Title, Above Products, Below Products
product_optionProduct Custom OptionsProduct Detail: Product Options (one per component)
checkoutCheckout Cart, Checkout, Success, Failure, PayPal Review
checkout_stepCheckout steps added to the built-in onesCheckout: Before Shipping Method, Before Payment (one per component)
express_checkoutExpress payment buttonsCheckout and Checkout Cart: Express Checkout (one per component)
accountSign-in, registration and password forms, Logout Success, Login as Customer, My Account, Orders and Returns
account_sectionThe sections of My AccountMy Account: Section (one per component)
generalNot Found, and every component without a groupProduct, Product Media, Product Detail, Product Listing, Checkout, Checkout Cart

For example, a component with group product_attribute and availableOn catalog_product_view* can be dropped below the price of the Product Detail component.

Slots ​

A slot is a named area of your component that holds other components. Declare each slot as a field of type Slot; the field name is the slot name your component renders.

graphql
type VendorPanel @component(…) {
  default: Slot @slot(label: "Content")
  "Optional: only components of these groups, each at most once"
  actions: Slot @slot(label: "Buttons", allow: ["text", "media"], max: 1)
}
ArgumentWhat it does
labelName of the slot in the outline, and on the empty-slot placeholder in the preview
allowThe picker opened from this slot lists only these groups, and dragging other components in is blocked. Omitted: any component.
maxHow many times each component (counted by code) can be in this slot. The picker greys out a component that reached the limit and marks it Already added. Omitted: no limit.
nameThe stored slot name, when it is not a valid field name (product_info_additional: Slot @slot(label: "Product Options", name: "product.info.additional"))

How the children reach your component:

Default slotNamed slotTest for an empty slot
<slot /><slot name="actions" />$slots.actions

Slots of built-in components ​

Your module can change the slots of a Lynx component with extend type, without copying its definition. List the module that declares the component in your <sequence>.

Let your group into a built-in slot. The Below Add to Cart slot (info_after) of the simple Product Detail (ProductDetailSimple in OOCS_LynxCatalog) accepts a fixed list of groups. Replace the slot with your group added:

graphql
extend type ProductDetailSimple {
  info_after: Slot @slot(label: "Below Add to Cart", allow: ["layout", "text", "media", "form", "product_list", "product_attribute", "general", "vendor_badges"]) @replace
}

Each product type has its own type (ProductDetailConfigurable, ProductDetailBundle, ProductDetailGrouped, ProductDetailDownloadable), so repeat it for every type the component should fit. Often it is simpler to give your component a group the slot already accepts: the slots of Product Detail and Product Listing take layout, text, media, form and general, see Groups.

Add a slot to a built-in component. Declaring a new slot the same way makes it appear in the editor's outline, but the component only shows children where its file renders that slot. Override the component in your theme to render it (see Themes and Overrides):

graphql
extend type ProductDetailSimple {
  title_after: Slot @slot(label: "Below Title")
}
vue
<!-- In your theme's override of Product/Simple.vue, where the children go -->
<div v-if="$slots.title_after" class="grid gap-4"><slot name="title_after" /></div>

Slot names you add are yours to keep stable: layouts store children under them, so renaming one later empties it on every saved page.

4. Build and see it ​

After adding or changing a component:

bash
bin/magento setup:upgrade                     # first time only: registers the module
bin/magento lynx:theme:setup OOCS_LynxVue     # first time only: adds the module to the build
bin/magento lynx:static:deploy                # builds the theme bundles
bin/magento cache:clean

lynx:theme:setup adds only enabled modules that have a view/frontend/lynx/vue/src folder, so run it again after enabling your module. Name the Lynx themes you build, for example OOCS_LynxVue; without names it sets up every theme set up before. A change to a .graphqls file needs bin/magento cache:clean lynx_component, and lynx:static:deploy to rewrite the props types; when you use lynx:compile, run bin/magento lynx:compile components again too. In production mode also run setup:di:compile and setup:static-content:deploy. See Setup for theme setup, the dev server and watching files.

Open the editor: the component is in the picker under its group, on the pages and zones you allowed.

When your module is disabled or removed ​

Layouts keep the nodes of a component whose module is disabled or uninstalled; nothing is deleted. While the component is not registered:

  • Storefront: the node is left out, together with everything in its slots, and its provider does not run. Each render logs an error to var/log/system.log naming the layout path, the zone, the node and its component.
  • Editor: the outline shows the node with Component not available, and the preview draws a placeholder in its place, so the merchant can delete it or bring the module back.
  • Enabling the module again brings the node back with its settings.

Nodes your code adds to a layout without a name (a placeholder, a script) are not checked against the registry and render by their component alone.