Customize POS styles and behavior

Ask about this Page
Copy for LLM
View as Markdown

Author the InStore POS theme with palette tokens, MUI component overrides, responsive layout tokens, and POS-specific style hooks.

This page explains what goes in a theme and how to shape the POS with it. Two companion pages cover the mechanics:

Prerequisites

MUI theming concepts

The InStore POS theme uses the public MUI theme shape plus a small number of InStore-specific fields. These MUI concepts appear throughout the payload:

  • Theme: the object holding styling decisions such as palette, typography, spacing, breakpoints, shape, and per-component overrides.
  • Palette: named color tokens such as primary.main, secondary.main, text.primary, and background.default.
  • Token: a named value in the theme that other rules reference instead of hard-coding a literal value.
  • Slot: a named structural part inside a component, such as root, label, startIcon, or paper.
  • styleOverrides: per-slot style rules for a component.
  • defaultProps: default React props applied to each instance of a component.
  • variants: conditional style blocks that apply when a component matches a prop or className.
  • State class: a class MUI adds for interaction state, such as Mui-disabled, Mui-focused, or Mui-selected.

InStore-specific theme fields

The terms below are specific to how the InStore POS and its Theming API work.

  • Bundled default theme: the theme that the InStore POS ships with and uses when no API theme is set. It has the same shape as the API payload, so everything on this page applies to it; your tenant's look is driven by the theme you store through the Theming API.
  • customTokens: POS-specific responsive layout flags that aren't part of the MUI stock theme (header/footer visibility, sidebar structure). Documented in Responsive layout tokens.
  • __fn:ownerState: a JSON discriminator used inside styleOverrides to express the MUI callback-form conditional styling without sending a JavaScript function. The API accepts JSON payloads only, and accepting arbitrary function strings would be an unacceptable code-injection vector, so the MUI (ownerState) => ({...}) form is replaced by a strictly validated { "__fn": "ownerState", "cases": { ... } } shape. Only "ownerState" is allowed; any other discriminator is rejected with a 400. To define conditional component styles with the supported JSON shape, see The ownerState escape hatch.

Slots, styleOverrides, variants, and defaultProps

These are the main building blocks for overriding a component:

  • defaultProps set baseline props for each instance of a component.
  • styleOverrides target a component slot, such as components.MuiButton.styleOverrides.root.
  • variants apply only when props match. The POS uses class-name variants such as { "props": { "className": "instore-primary-action" }, "style": { ... } } to target a specific POS surface.
Use styleOverrides when a change should apply to every instance of a component. Use a variants entry or nested instore-* selector when a change should apply to one POS surface.

How overrides compose

Theme rules compose from general to specific:

  1. Palette, typography, spacing, shape, and breakpoints establish the token values.
  2. defaultProps set baseline component behavior.
  3. styleOverrides style each component slot.
  4. variants apply when a prop or class-name matcher matches.
  5. State classes such as Mui-disabled and Mui-selected apply interaction-state styles.

When two rules style the same property, CSS specificity decides which rule has precedence. A class-name variant or state-specific selector usually has precedence over a bare slot rule.

How themes work

The InStore POS ships with a bundled default theme that the runtime uses by default. The InStore Theming API stores a tenant-scoped theme that overlays the bundled default. At runtime, the POS deep-merges the API theme on top of the bundled default. API values win at the leaf level, except component overrides under components.MuiX, which are replaced atomically. For more information, see Leaf merge versus atomic component replacement.

Conceptually, treat the bundled default theme as the baseline your tenant theme overlays: the theme you store through the Theming API is what's applied, and any value it doesn't set falls back to the bundled default.

The InStore POS modules inherit the merged theme through the MUI useTheme. The /theme API applies to non-core POS functionality such as Cart, Catalog, Transactions, and Receipt reprint. To theme core functionality such as payment, refund, cash management, and device management in an older modular-store host, see Override product module styling.
InStore POS with the bundled default theme InStore POS after applying a branded theme
The theme fetch happens at bootstrap, so a persisted change takes effect on the next load of the POS. For the per-method request flow and timing, see How a request takes effect in the InStore POS.

Leaf merge versus atomic component replacement

Your tenant theme overlays the bundled default rather than replacing it wholesale, but it does so under two different rules, and the second one surprises people:

  • Most fields merge at the leaf. For palette, typography, spacing, shape, and breakpoints, only the individual values you send change; their siblings keep the bundled default. Set palette.primary.main, and palette.secondary is left exactly as it was.
  • Each components.MuiX block is replaced as a whole. When your payload includes a component, say components.MuiButton, that entire block (defaultProps, every styleOverrides slot, and every variants entry) replaces the stored MuiButton definition. Components you don't mention are untouched, but the one you do mention is taken exactly as you send it, with nothing merged in from the previous block.
This holds for both PUT and PATCH. Even a PATCH, which preserves the top-level fields you omit, replaces any components.MuiX block you include wholesale.
Leaf fields merge value-by-value, keeping siblings; a components.MuiX block is taken whole, so resend the entire block under both PUT and PATCH.
What this means for you: send the complete component block, including the parts you want to keep. If you send only the slot or variant you are changing, everything you left out is dropped, not preserved. The reliable workflow is to GET /theme, find the component block, edit it in place, and send the whole block back.
For example, suppose the stored theme styles MuiButton like this:
Stored MuiButton blockjson
{
  "components": {
    "MuiButton": {
      "defaultProps": { "disableElevation": true },
      "variants": [
        {
          "props": { "className": "instore-primary-action" },
          "style": { "backgroundColor": "primary.main" }
        },
        {
          "props": { "className": "instore-search-scan-btn" },
          "style": { "backgroundColor": "secondary.main" }
        }
      ]
    }
  }
}
You want to recolor only the primary action, so you PATCH MuiButton with just that one variant:
PATCH: only the primary-action variantjson
{
  "components": {
    "MuiButton": {
      "variants": [
        {
          "props": { "className": "instore-primary-action" },
          "style": { "backgroundColor": "tertiary.main" }
        }
      ]
    }
  }
}
Because the block is replaced, not merged, the response (and the applied theme) is exactly what you sent. The disableElevation default prop and the instore-search-scan-btn variant are now gone:
Resulting MuiButton blockjson
{
  "components": {
    "MuiButton": {
      "variants": [
        {
          "props": { "className": "instore-primary-action" },
          "style": { "backgroundColor": "tertiary.main" }
        }
      ]
    }
  }
}
To keep them, include disableElevation and the scan-button variant in the same payload.

Why replace instead of deep-merge?

Component blocks mix nested selectors, pseudo-class rules, and an array of variants. Deep-merging that structure is ambiguous and fragile.
For example, there is no unsurprising way to merge two variants arrays. A silent merge could also keep default rules you never meant to preserve.

That can produce conflicting or duplicated styles. It can also create specificity battles that are hard to debug.

Atomic replacement keeps each component predictable and self-contained. The block you send is exactly what applies.

What you author is what renders, without leftover rules from the bundled theme. The cost is resending the full block.

Default and POS starter themes

You don't author a theme from a blank page. Two starting points are available:

  • Default theme: the bundled look the POS falls back to when no theme is stored. It resembles the legacy InStore look. Sending an empty body to PUT /theme resets the tenant to this look.
  • POS starter theme: an industry-typical configuration that reflects how most retailers expect a POS to look. Load it with PUT /theme and then adjust it with PATCH to match your brand.
Loading a starter theme is a single PUT of the provided payload; from there, every change is a small PATCH. To review the request and empty-body behavior, see PUT /theme.

Back up your theme payloads

The InStore POS stores only the currently active theme. It keeps no server-side version history or change log, and commercetools cannot recreate a configuration you overwrite or lose. Build a simple backup habit:

  • Save every payload. Keep your PUT and PATCH request bodies, and the GET responses, in your own version control or file store. These are also how you maintain alternative "modes" (seasonal, per-brand). The server keeps only one, so you track the rest.
  • Read before you write. Run GET /theme before each change and use its response as the base for your next request, so you always edit the live state.
  • Validate generated payloads. If you use generated or assisted theme payloads, review the JSON before applying it. Confirm that components.MuiX blocks are complete and that values follow the validation rules.

The theme payload

Your theme is expressed in the MUI public ThemeOptions shape plus a small set of InStore-specific fields, so standard MUI theming knowledge applies directly. Three concepts recur throughout:
  • Palette: named color tokens referenced as strings (for example, 'primary.main') elsewhere in the theme.
  • Components: per-MUI-component overrides; each accepts defaultProps, styleOverrides, and variants.
  • customTokens: POS-specific responsive flags for layout, not part of stock MUI.
For the full list of top-level fields and their types, see the Theme payload fields table in the API reference. The rest of this page explains how to author each part.

Customize MUI component overrides

Under components, you key by MUI component name (MuiButton, MuiAppBar, MuiPaper, and so on) and provide any combination of three sub-shapes:
components.MuiButton examplejson
{
  "components": {
    "MuiButton": {
      "defaultProps": {
        "disableElevation": true
      },
      "styleOverrides": {
        "root": {
          "borderRadius": "8px",
          "textTransform": "none",
          "&:hover": { "boxShadow": "none" }
        }
      },
      "variants": [
        {
          "props": { "className": "instore-primary-action" },
          "style": {
            "backgroundColor": "primary.main",
            "color": "primary.contrastText",
            "height": 68,
            "fontSize": 20
          }
        }
      ]
    }
  }
}
Component override values aren't plain CSS. The InStore POS resolves them as MUI sx. That's what lets your overrides reference palette tokens by string instead of repeating hex values.

Reference palette tokens in component overrides

Inside styleOverrides, variants[].style, and __fn cases, reference a palette token by string and the runtime resolves it against the active palette:
  • Brand colors: 'primary.main', 'primary.contrastText', 'secondary.main', 'tertiary.main'.
  • Text and surfaces: 'text.primary', 'text.secondary', 'background.default', 'background.paper', 'divider', 'common.white', 'common.black'.
  • Semantic states: 'action.hover', 'action.disabledBackground', 'action.disabled'.
MUI auto-derives light, dark, and contrastText from main for its standard colors (primary, secondary, error, warning, info, success), so you can reference 'primary.dark' for a hover state even if your palette only sets primary.main. The custom tertiary color is not auto-derived. Define any shade you reference (tertiary.dark, tertiary.contrastText) explicitly in the palette, as in the worked example below.
Hard-coded hex vs. a token referencejson
{
  "components": {
    "MuiAppBar": {
      "styleOverrides": {
        "root": {
          "backgroundColor": "primary.main",
          "color": "primary.contrastText"
        }
      }
    }
  }
}
The benefit is to define once and propagate everywhere: change palette.primary.main, and every override that references it updates together. Re-branding becomes a one-line edit instead of a search-and-replace across scattered hex values. The bundled POS theme references tokens throughout for exactly this reason.
In a JSON payload you can't send the MUI ({ theme }) => ({ ... }) callback, so the token-string form is the API-safe way to stay palette-aware. For prop-conditional styles, use the ownerState escape hatch. Its case styles resolve as sx too, so they can reference tokens.
Because override values resolve as sx, numeric spacing and radius are theme multiples, not pixels: "borderRadius": 8 becomes 8 × the theme's base radius, and "padding": 2 is 2 × the 8px spacing unit. For an exact pixel value, use a unit string: "borderRadius": "8px". Any value can also be a responsive map ({ "xs": ..., "md": ... }).

The ownerState escape hatch

Almost everything in a theme is plain data that serializes to JSON directly. The one exception is the MUI callback form of styleOverrides, ({ ownerState }) => ({ ... }), which is a JavaScript function, and JSON cannot carry functions.
The __fn escape hatch is a small domain-specific language (DSL) that encodes that function as plain data so it survives the round trip through the API. Instead of a function body, you send a discriminator ("__fn": "ownerState") and a cases map; when the InStore POS applies the theme, it reconstructs the equivalent callback from that data. Sending an executable function string instead would be a code-injection vector, so the schema accepts only this fixed shape: "ownerState" is the only discriminator, and any other value is rejected with a 400.
Each case key is a single prop=value test against the component's ownerState. The runtime applies the first matching case, falling back to the default key, then resolves the result as sx, so a case can reference palette tokens such as 'primary.main', just like any other override.
MUI callback form (in code)tsx
{
  components: {
    MuiButton: {
      styleOverrides: {
        root: ({ ownerState, theme }) => {
          if (ownerState.size === 'small') return { padding: '4px 8px' };
          if (ownerState.color === 'primary')
            return {
              backgroundColor: theme.palette.primary.main,
              color: theme.palette.primary.contrastText,
            };
          return { padding: '6px 16px' };
        },
      },
    },
  },
}
Equivalent API payloadjson
{
  "components": {
    "MuiButton": {
      "styleOverrides": {
        "root": {
          "__fn": "ownerState",
          "cases": {
            "size=small": { "padding": "4px 8px" },
            "color=primary": {
              "backgroundColor": "primary.main",
              "color": "primary.contrastText"
            },
            "default": { "padding": "6px 16px" }
          }
        }
      }
    }
  }
}
Each case key tests a single ownerState property (the part before =) against a value (the part after). Only { "__fn": "ownerState", "cases": { ... } } is accepted. To check the schema rules that reject unsupported function payloads, see Validation and errors.

Responsive layout tokens

customTokens controls non-MUI layout decisions: header/footer visibility and sidebar structure. Every token accepts either a bare value that applies at every viewport size, or a responsive value that varies per breakpoint.

Breakpoints in brief

MUI segments the viewport into five named breakpoints (xs, sm, md, lg, xl) defined by minimum widths. The default thresholds are xs: 0, sm: 600, md: 900, lg: 1200, xl: 1536 (pixels). You can override these via the top-level breakpoints field in your theme; customTokens and the MUI component styling both read from the same definitions, so a single change propagates everywhere.

Bare value vs. responsive value

  • Bare value (true, 5, 'inline'): applies at every viewport. Use this when the layout decision is genuinely not viewport-dependent. Shorter, easier to read, and cheaper at runtime.
  • Responsive value ({ xs?, sm?, md?, lg?, xl? }): applies a different value per breakpoint. Use this when the same screen needs different chrome on a phone, device, or POS terminal (for example, drilldown sidebars on small screens and inline sidebars on large ones). The runtime resolves from the current breakpoint downward: if the active breakpoint is lg and you only set xs and md, the runtime returns the md value. If no key matches at or below the current breakpoint, it falls back to the bundled default.
The runtime walks down from the active breakpoint to the nearest value at or below it, so an active lg with only xs and md set resolves to md, and nothing at or below falls back to the bundled default.

Field reference

FieldAttributesTypeDescriptionExample
layoutObjectVisibility flags for the top-level chrome.
headerVisibleBoolean | ResponsiveValueWhether the top header bar renders.false or { "xs": false, "md": true }
mobileFooterBoolean | ResponsiveValueWhether the mobile status or footer bar renders.{ "xs": true, "md": false }
desktopFooterBoolean | ResponsiveValueWhether the desktop status bar renders.{ "xs": false, "md": true }
sidebarObjectSidebar structure.
showLogoBoolean | ResponsiveValueWhether the logo renders in the sidebar header.true
showCollapseButtonBoolean | ResponsiveValueWhether the collapse and expand toggle renders.{ "xs": true, "md": false }

A bare scalar applies at every viewport:

{ "customTokens": { "layout": { "headerVisible": false } } }

A responsive boolean map gives different behavior per breakpoint:

{
  "customTokens": {
    "sidebar": { "showCollapseButton": { "xs": true, "md": false } }
  }
}

Apply the theme across the InStore POS

The InStore POS fetches the theme from the Theming API at bootstrap and applies it automatically, so a standard integration never needs to apply the theme from code. The active theme applies to non-core POS functionality such as Cart, Catalog, Transactions, and Receipt reprint.

Supplying your own theme payload at runtime from the host app is not supported. For theme switchers, live previews, or A/B testing, deliver theme changes through the Theme API and reload the POS to see them.
If you have integrated InStore through the older modular-store host, the integration surface is different. To theme core functionality such as payment, refund, cash management, and device management, use Override product module styling. This page describes the theming used by the InStore POS.

Inspect and override a specific element

Use Chrome DevTools to find what to target. Each element exposes four signals you can read straight from its class list.

Class-name conventions

For how the MUI slot, state, and prop classes work in general, see Slots, styleOverrides, variants, and defaultProps. The list below is the quick reference for what each class on a POS element tells you.

Inspection workflow

  1. Open the POS in a desktop browser, right-click the element you want to change, and choose Inspect.
    Inspecting the primary action button in DevTools
  2. Read the element's class list to identify the MUI component (.MuiButton-root to MuiButton), the slot (label, startIcon, and so on, usually root), the variant/color props (MuiButton-contained), and any instore-* hook.
    Reading the class list in the Elements panel
When you modify a specific component, make sure your selector scope covers only elements that include an ID or class that starts with instore-.
  1. Decide whether the rule is global or specific. Global rules go in components.MuiX.styleOverrides; per-element rules go in components.MuiX.variants with a className matcher (preferred when an instore-* hook exists).
  2. Write the override and apply it with a PATCH /theme request. It deep-merges your change into the stored theme, so the rest of your configuration is preserved. Avoid PUT for incremental edits. It replaces the whole theme and unsets every field you omit. Then reload the POS.
  3. Reload the page and re-inspect. The Styles panel shows whether your rule took effect or was overridden by a more specific selector.

    Verifying the new style applied in the Styles panel
  4. If your rule is crossed out, raise specificity or move the rule into variants.

Worked example: recolor the primary action button

DevTools shows the button has classes MuiButton-root MuiButton-contained MuiButton-containedPrimary instore-primary-action. You want the checkout call-to-action button green, not every contained button. Define the green once as a palette token (tertiary) and reference it from the instore-primary-action variant; the Mui-disabled state then draws from the MUI semantic action tokens rather than another hard-coded gray.
Re-color the primary action buttonjson
{
  "palette": {
    "tertiary": {
      "main": "#708238",
      "dark": "#5D6D2F",
      "contrastText": "#ffffff"
    }
  },
  "components": {
    "MuiButton": {
      "styleOverrides": {
        "root": {
          "&.MuiButton-containedPrimary.Mui-disabled, &.MuiButton-primary.Mui-disabled": {
            "backgroundColor": "primary.main",
            "color": "primary.contrastText",
            "opacity": 0.5
          },
          "&.MuiButton-containedSecondary.Mui-disabled, &.MuiButton-secondary.Mui-disabled": {
            "backgroundColor": "secondary.main",
            "color": "secondary.contrastText",
            "opacity": 0.5
          },
          "&.MuiButton-contained.colorTertiary": {
            "backgroundColor": "tertiary.main",
            "color": "tertiary.contrastText"
          },
          "&.MuiButton-contained.colorTertiary.Mui-disabled, &.colorTertiary.Mui-disabled": {
            "backgroundColor": "tertiary.main",
            "color": "tertiary.contrastText",
            "opacity": 0.5
          },
          "&.Mui-focusVisible": {
            "outline": "2px solid #90caf9",
            "outlineOffset": "2px"
          }
        }
      },
      "variants": [
        {
          "props": {
            "className": "instore-primary-action"
          },
          "style": {
            "backgroundColor": "tertiary.main",
            "color": "tertiary.contrastText",
            "height": "68px",
            "borderRadius": "10px",
            "fontSize": "20px",
            "lineHeight": 1,
            "paddingLeft": "42px",
            "paddingRight": "42px",
            "paddingTop": "13px",
            "paddingBottom": "13px",
            "&:hover": {
              "backgroundColor": "tertiary.dark"
            },
            "&:active": {
              "backgroundColor": "#4A571F"
            },
            "&.Mui-focusVisible": {
              "outline": "2px solid #90caf9",
              "outlineOffset": "2px"
            },
            "&.instore-primary-action.Mui-disabled, &.instore-primary-action:disabled": {
              "backgroundColor": "#D3D1CA",
              "color": "#444444",
              "opacity": 1
            }
          }
        }
      ]
    }
  }
}
Send this with a PATCH /theme request, not PUT. The payload above is a partial theme (only palette.primary and components.MuiButton), so PATCH deep-merges it onto your stored theme and leaves everything else intact, whereas PUT would unset every field you did not send. The example sends the whole MuiButton block on purpose. PATCH replaces a components.MuiX block atomically (it is not deep-merged), so any slot you omit here would be dropped. Then reload and re-inspect. The Styles panel now shows the new backgroundColor. Because the variant references tertiary.*, you can re-tune the green later by editing palette.tertiary alone. If the rule does not take effect, check that no more-specific rule (for example, a global .MuiButton-containedPrimary selector elsewhere) is winning the cascade.
Result: green primary action button
When in doubt, prefer variants with a class matcher over deeply nested &.MuiButton-... selectors. Variants are clearer to read and survive future MUI minor-version changes that may rename internal classes.

Override hierarchy in practice

Theme rules compose in layers, from global to specific. See How overrides compose for the full model. The example below shows how those layers stack up on a real InStore POS element: the primary action button. It is styled by the bundled default theme's palette, by components.MuiButton.defaultProps and styleOverrides (which apply to every button), and finally by a class-targeted variants entry that activates only for buttons tagged instore-primary-action.
Composed override for the primary action buttonjson
{
  "palette": {
    "primary": { "main": "#15375C", "contrastText": "#ffffff" }
  },
  "components": {
    "MuiButton": {
      "defaultProps": { "disableElevation": true },
      "styleOverrides": {
        "root": {
          "borderRadius": "8px",
          "textTransform": "none"
        }
      },
      "variants": [
        {
          "props": { "className": "instore-primary-action" },
          "style": {
            "backgroundColor": "primary.main",
            "color": "primary.contrastText",
            "height": 68,
            "fontSize": 20,
            "&:hover": { "backgroundColor": "primary.dark" },
            "&.Mui-disabled": {
              "backgroundColor": "action.disabledBackground",
              "color": "action.disabled"
            }
          }
        }
      ]
    }
  }
}
Every <Button> in the POS picks up layers 1 to 3. Only buttons rendered with className="instore-primary-action" additionally pick up layer 4, which is how the bundled default theme establishes the recognizable checkout call-to-action button. Because the variant references primary.main (and the derived primary.dark for hover) rather than one-off hex, re-branding that button is a single edit to palette.primary. The button, its hover, and every other primary-colored surface move together. Leave the structural height and fontSize alone unless you specifically want a different shape.

Common style hooks

The bundled default theme tags structural UI surfaces with instore-* class hooks: on the header, sidebar, search bar, cart, totals money box, dialogs, and more. Target a hook from your own overrides by re-declaring the matching rule on the appropriate MUI component.
The POS style reference holds the complete inventory: every surface mapped to its class, MUI component, styleOverrides slot or variants entry, and the properties you typically change. That appendix also covers the cart line-item card and its rows, the totals money box, the camera scanner, and the behavior and layout toggles.
Re-brand the header barjson
{
  "components": {
    "MuiAppBar": {
      "variants": [
        {
          "props": { "className": "instore-header-bar" },
          "style": {
            "backgroundColor": "background.paper",
            "color": "text.primary",
            "boxShadow": "0 1px 2px rgba(0,0,0,0.05)"
          }
        }
      ]
    }
  }
}

Troubleshooting

  • Theme not applied to federated modules? Confirm the theme has been published for the tenant and location you are running against, then reload the POS.
  • PATCH didn't merge inside MuiButton? The block under each components.MuiX is atomic. Re-send the full block to keep existing entries.
  • 400 with a SafeCssString error? Search your payload for <, >, backticks, or stray newlines inside string values. To review the rejected character rules, see Validation and errors.
  • Theme persisted but UI unchanged? Reload the POS app. Themes fetch at bootstrap.