Custom CSS for your menu page (developer reference)
Who this article is for
This is the reference for the person who writes the custom CSS of a menu page: a web developer working for the venue, or the venue's technical person. If you only want to choose colours, fonts and pictures, you do not need it; use Menu appearance: choose and customise your menu page instead. Do the settings there first and write CSS only for what the settings cannot do.
Plan and access: custom CSS is the paid feature Menu Custom CSS, on top of Menu Templates. The Custom CSS tab in Menu & tables → Menu appearance is only open to a company administrator.
What is allowed, and why
Custom CSS is CSS only. There is no JavaScript and no HTML in it. Your CSS changes the menu page that customers open from the QR code, and nothing else.
Three decisions keep that safe for you and for your customers:
- Scoped to the menu page. Every selector you write is automatically placed inside the menu page, so your CSS cannot reach the rest of the site.
- Protected parts. The cart drawer, the dish options window, the call-a-waiter button and the order notices are outside the reach of your stylesheet. You cannot restyle or hide them. They follow your colours, corner roundness and font through the CSS variables below, but a mistake in your CSS can never stop a customer from ordering or calling a waiter.
- No outside addresses. A stylesheet cannot load other files, fonts or images by address. Pictures come only from your own library, through a variable (see below).
How it is applied
- In the Custom CSS tab you type in a code editor. A moment after you stop typing, the server checks the stylesheet and marks problems on the line and column. A click on a problem jumps to it. The editor counts the characters you have used, out of the 65,536 allowed.
- The stylesheet is saved with the rest of your draft, and the preview frame shows it at once. A stylesheet that has problems is not saved or published: the draft keeps the last version without problems until you fix it.
- When you press Publish, the server compiles it again. It does not copy your text: it reads every rule, checks it against the rules in this article, and writes the result from the parts it accepted. Comments disappear, spacing is rebuilt and every selector gets its scope.
- Customers receive the compiled stylesheet, linked in the head of the menu page, so there is no flash of unstyled content.
Insert an example adds a small working stylesheet for the template you chose. It is a good starting point.
The tab also has a Hooks and rules panel with every part and variable of the current template, each with a copy button.
The hooks: data-part
Every section of a template carries a name in a data-part attribute. You style it with an attribute selector:
[data-part="item-card"] {
border-radius: 16px;
}
These names are the public CSS interface of the template. We keep these names stable and will announce a change in advance. A selector for a part that the current template does not have simply matches nothing, so CSS written for one template does no harm on another.
Your rules win over the template's own styles without !important, with two exceptions described in the troubleshooting section.
Some parts exist only in certain situations (a logo, a tagline, a table label, the quantity stepper after a dish is added). The tables say so.
Classic (classic)
| Part | What it is |
|---|---|
header |
The top bar with the logo, the venue name, the table label and the language, currency and theme buttons. |
category-nav |
The sticky row of category links under the header (only with more than one category). |
category-link |
One link in that row. |
allergen-note |
The small note about allergens (only when some dish has allergens). |
category-section |
One whole category: its title and its dishes. |
category-title |
The heading of a category. |
category-photo |
The small category picture beside the title (when the category has one). |
item-card |
One dish card. |
item-photo |
The photo of a dish. |
item-name |
The name of a dish (with the sold-out badge when needed). |
item-description |
The description of a dish. |
item-allergens |
The allergens of a dish. |
item-price |
The price of a dish. |
quantity-stepper |
The minus and plus control that replaces the Add button once a dish is in the order. |
add-button |
The Add button of a dish. |
cart-bar |
The bar at the bottom with the number of dishes and the total; it opens the cart. |
Hero (hero)
| Part | What it is |
|---|---|
page-background |
The layer behind the whole page: a soft gradient of the background colour, or your background picture under a veil. |
hero |
The whole header block. |
hero-cover |
The cover layer inside the header (not present with the Plain header). |
logo |
The box with your logo (only if the company has a logo). |
company-name |
The venue name in the header. |
table-label |
The pill with the table and area (only on a table's QR page). |
tagline |
Your tagline under the name (only if you wrote one). |
language-button |
The round language button (only if the menu has more than one language). |
links |
The row of profile links. |
link |
One profile link. |
link-icon |
The icon of a profile link. |
info-chips |
The row of info chips. |
info-chip |
One info chip. |
info-chip-icon |
The icon of an info chip. |
info-chip-label |
The label of an info chip. |
info-chip-value |
The value of an info chip, for example the WiFi password. |
hours |
The opening hours panel. |
hours-row |
One day in the opening hours. |
search |
The search form. |
search-input |
The search field. |
allergen-note |
The small note about allergens. |
no-results |
The line that says nothing was found when a search has no match. |
category-pills |
The row of category pills (or tabs) that follows the scroll (only with more than one category). |
category-pill |
One category pill. |
category-section |
One whole category: its title and its dishes. |
category-title |
The heading of a category. |
item-card |
One dish card. |
item-photo |
The photo of a dish (when Dish photos is on). |
item-name |
The name of a dish. |
item-description |
The description of a dish (not shown in the Compact layout). |
item-allergens |
The allergens of a dish. |
item-price |
The price of a dish, in two parts. |
price-amount |
The number of the price. |
price-currency |
The currency code of the price. |
quantity-stepper |
The minus and plus control that replaces the Add button once a dish is in the order. |
add-button |
The round Add button of a dish. |
tab-bar |
The floating bar at the bottom. |
tab |
One tab of that bar: All, each of your tabs and Order. |
tab-badge |
The small number on the Order tab (only when the order is not empty). |
cart-bar |
The order bar that takes the place of the tab bar when Bottom tab bar is switched off. |
Group tabs: data-group
Each of your own tabs from Menu tabs is a tab that also carries data-group with the key you gave it (the Key field of the tab). So you can style one tab on its own:
[data-part="tab"][data-group="drinks"] {
letter-spacing: 0.04em;
}
To reach only the tabs that are not yours (All and Order), use [data-part="tab"]:not([data-group]).
CSS variables
The template puts its settings on the page as CSS variables, which you can read with var() and override on a part. Colours come in three: --menu-<name> is the colour, --menu-on-<name> is a text colour that reads well on it (white or near-black) and --menu-on-<name>-muted is a softer version of that.
| Variable | Meaning | Classic | Hero |
|---|---|---|---|
--menu-primary, --menu-on-primary, --menu-on-primary-muted |
Main colour | yes, if a main or brand colour is set | yes |
--menu-accent, --menu-on-accent, --menu-on-accent-muted |
Accent colour | no | yes |
--menu-background, --menu-on-background, --menu-on-background-muted |
Page background | no | yes |
--menu-surface, --menu-on-surface, --menu-on-surface-muted |
Cards and panels | no | yes |
--menu-text, --menu-on-text, --menu-on-text-muted |
Text colour | no | yes |
--menu-font-heading, --menu-font-body |
The chosen fonts, ready for font-family |
no | yes |
--menu-radius, --menu-radius-control, --menu-radius-pill |
Corner radius of cards, of fields and small buttons, and of pills | no | yes |
--menu-layout-items, --menu-layout-category-nav, --menu-layout-header, --menu-layout-blocks |
The chosen layout, as a word | no | yes |
--menu-toggle-search, --menu-toggle-links, --menu-toggle-chips, --menu-toggle-bottom-nav, --menu-toggle-item-photos, --menu-toggle-allergens |
The switches, as true or false |
no | yes |
--menu-image-cover, --menu-image-background |
Only the name of the picture chosen in Settings, and only when one is chosen; not the picture itself (use --asset-<name> for that) |
no | yes |
--asset-<name> |
A picture from your library, ready to use as a background | yes | yes |
The layout and switch variables only report the current setting. Changing them in CSS does not switch a section on or off; use the Settings tab for that.
To change a variable, set it on the part that should use the new value:
[data-part="item-card"] {
--menu-radius: 8px;
}
A variable set on a part reaches that part and everything inside it, not the rest of the page.
Pictures: only through var(--asset-<name>)
You cannot write an address in your CSS. Upload the picture in the Images tab, give it a name (for example terrace) and use the variable --asset-terrace. It holds a ready picture reference, so use it where a picture goes, such as background-image or background:
[data-part="hero-cover"] {
background-image: var(--asset-terrace);
background-size: cover;
background-position: center;
}
A name that does not exist in the library paints nothing. Copy CSS variable on the picture's card copies the exact text. A picture that your CSS uses cannot be deleted until you remove it from the stylesheet.
The rules
What a stylesheet may contain
- Normal rules (
selector { property: value; }) and the at-rules@media,@supports,@containerand@keyframes. Nothing else: no@import, no@font-face, no@layer, no@charset. @media,@supportsand@containercan be nested inside each other up to 4 levels. Rules inside rules (CSS nesting, the&selector) are not supported.- At most 65,536 characters and 2,000 selectors (every selector in a comma list counts).
- Comments (
/* ... */) are fine and are removed. !importantis allowed, at the end of a value.
Selectors
- Only parts of the menu page. Selectors for
html,body,head,:root,:hostand::backdropare refused, and so are the attributesdata-menu-rootanddata-protected. - A selector may not start with a combinator (
>,+,~). Write[data-part="a"] > [data-part="b"], not> [data-part="b"]. - The arguments of
:is(),:where(),:not()and:has()are selectors too, and follow the same rule: an argument may not start with+,~or|, nor with>(except inside:has()). This is refused asCSS_FORBIDDEN_SELECTOR. - Allowed pseudo-classes with arguments:
:is(),:where(),:not(),:has(),:nth-child(),:nth-last-child(),:nth-of-type(),:nth-last-of-type(),:lang()and:dir(). Plain pseudo-classes such as:hover,:active,:focus-visibleand:disabled, and pseudo-elements such as::beforeand::after, are fine. - No escape sequences (backslash) in selectors.
Values
- No
url(), and no other function that loads an address (src(),image(),image-set()and similar). - Allowed functions only:
- general:
var,calc,min,max,clamp,env - colours:
rgb,rgba,hsl,hsla,hwb,lab,lch,oklab,oklch,color,color-mix - gradients:
linear-gradient,radial-gradient,conic-gradientand theirrepeating-forms - transforms:
translate,translateX,translateY,translateZ,translate3d,rotate,rotateX,rotateY,rotateZ,rotate3d,scale,scaleX,scaleY,scaleZ,scale3d,skew,skewX,skewY,matrix,matrix3d,perspective - filters:
blur,brightness,contrast,drop-shadow,grayscale,hue-rotate,invert,opacity,saturate,sepia - timing and layout:
cubic-bezier,steps,minmax,repeat,fit-content - shapes:
inset,circle,ellipse,polygon,rect
- general:
- No escape sequences in values, and no
@in a value. - Any function that is not on the list is refused, in a value and also in the condition of
@media,@supportsor@container. For example@supports selector(...)does not work. Functions in selectors such as::part()and::slotted()are refused too. - Text is restricted, so the stylesheet cannot write words into the page. A quoted string is allowed only in
font-family,font,grid-template-areas,grid-template,font-feature-settings,font-variation-settingsandfont-language-override.contentmay only be"",noneornormal. A custom property (--something) may not hold a string.list-style,text-overflowandquotesmay not takevar(). - The properties
behavior,expression,-moz-bindingand-webkit-bindingare refused.
Animation
- Names in
@keyframesmust start withmenu-and have something after it, for examplemenu-fade. Keyframe names are shared by the whole page, so yours are kept apart from the page's own. animationandanimation-namemay only namemenu-animations (ornone). They may not usevar()orenv().- Inside
@keyframesyou writefrom,toor percentages.
Characters
- Only ordinary ASCII white space (space, tab, line feed, carriage return, form feed). Other kinds of space, such as the non-breaking space that web pages and chat tools often paste, are refused.
- No
<outside a string, no control characters, no invisible formatting characters and no byte order mark (an invisible character some editors put at the start of a file). - No backslash escapes outside strings, and no
-->.
Tip: Keep your CSS in plain ASCII where you can. Invisible characters pasted from a web page or a chat are the most common reason for an error that "makes no sense".
Error messages and how to fix them
The editor lists every problem as Line 12, column 3: followed by the message, and marks it in the text. The server reports at most 50 problems at a time. Fix the first ones, and the others often disappear. The editor shows the message in your language, not the code. The CSS_... names below are the names the API uses for each kind of problem; a developer or support sees them in the network response of a failed save or check.
CSS_INVALID_SYNTAX: the CSS has a syntax error
The editor shows: The CSS has a syntax error. Check the line shown and try again.
The text is not well formed: a missing }, ) or quote, an unfinished comment, a declaration outside a rule, a selector with & or <, a keyframes name that does not start with menu-, an animation that names something else, at-rules nested too deep, or an invisible character.
/* refused: CSS_INVALID_SYNTAX */
[data-part="item-card"] {
border-radius: 16px;
[data-part="item-card"] {
border-radius: 16px;
}
/* refused: CSS_INVALID_SYNTAX */
@keyframes fade {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes menu-fade {
from { opacity: 0; }
to { opacity: 1; }
}
CSS_FORBIDDEN_AT_RULE: a rule that is not allowed
The editor shows: This CSS uses a rule that is not allowed (such as @import or @font-face).
You used an at-rule other than @media, @supports, @container or @keyframes, for example @import or @font-face. Remove it. Fonts are chosen in Settings (eight fonts are included), and pictures come from the Images tab.
/* refused: CSS_FORBIDDEN_AT_RULE */
@import "fonts.css";
CSS_FORBIDDEN_SELECTOR: a part of the page you may not change
The editor shows: This CSS targets a part of the page it may not change. Use only the parts of the menu (data-part).
The selector aims at the page itself (html, body, :root), at the protected parts, or starts with a combinator. Aim at a part of the template instead, and put variables on a part, not on :root. The same code is given for an argument of :is(), :where() or :not() that starts with +, ~, | or >, for an argument of :has() that starts with +, ~ or |, and for :host, :host-context and ::backdrop.
/* refused: CSS_FORBIDDEN_SELECTOR */
body {
background-color: #fafafa;
}
[data-part="category-section"] {
background-color: #fafafa;
}
CSS_FORBIDDEN_URL: addresses are not allowed
The editor shows: Addresses (url) are not allowed in the CSS. Use one of your uploaded pictures through its --asset variable.
You used url() or another way to load an address. Upload the picture in the Images tab and use its variable.
/* refused: CSS_FORBIDDEN_URL */
[data-part="hero-cover"] {
background-image: url("https://example.com/terrace.jpg");
}
[data-part="hero-cover"] {
background-image: var(--asset-terrace);
}
CSS_FORBIDDEN_FUNCTION: a function that is not allowed
The editor shows: This CSS uses a function that is not allowed.
The value uses a function that is not on the list above (for example attr() or counter()), an escape sequence, or an @. Use plain values or one of the allowed functions. The same code is given for ::part(), ::slotted() and any other function in a selector that is not on the selector list above, and for selector() in an @supports condition.
/* refused: CSS_FORBIDDEN_FUNCTION */
[data-part="item-name"] {
width: attr(data-width px);
}
[data-part="item-name"] {
width: calc(100% - 8px);
}
CSS_FORBIDDEN_PROPERTY: a property or value that is not allowed
The editor shows: This CSS uses a property that is not allowed.
The property is refused (behavior, expression, -moz-binding), or a quoted string is in a property that may not take one, or content has text, or list-style, text-overflow or quotes uses var(), or animation or animation-name uses var() or env(). The stylesheet may not write words into the menu page.
/* refused: CSS_FORBIDDEN_PROPERTY */
[data-part="item-card"]::after {
content: "Sale!";
}
[data-part="item-card"]::after {
content: "";
display: block;
height: 2px;
background-color: var(--menu-primary, #1d4ed8);
}
CSS_TOO_LARGE: too big
The editor shows: The CSS is too large. Shorten it and try again.
The stylesheet has more than 65,536 characters or more than 2,000 selectors. Remove rules you no longer use, join selectors that share the same declarations (a, b { ... }), and put repeated values in variables. Shortening spaces and comments also helps, because characters are counted as you wrote them.
Three examples
All three are valid as written. Change the colours and names to suit.
1. A dark header and rounded cards (Classic)
The Classic header is painted in the main colour, so it is changed with variables, not with a background rule. Set a main colour (or the brand colour) first.
/* Dark header, rounded cards */
[data-part="header"] {
--menu-primary: #111827;
--menu-on-primary: #f9fafb;
--menu-on-primary-muted: #cbd5e1;
border-radius: 0 0 24px 24px;
}
[data-part="item-card"] {
border-radius: 16px;
border-color: #e5e7eb;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.08);
}
@media (min-width: 640px) {
[data-part="item-card"] {
padding: 16px;
}
}
2. A hero with a picture (Hero)
Upload a wide picture called terrace in the Images tab. In Settings, leave Cover photo empty so that the CSS can place the picture (a cover photo chosen in Settings is written straight on the element and wins unless you add !important).
/* A picture from the library in the header */
[data-part="hero-cover"] {
background-image: var(--asset-terrace);
background-size: cover;
background-position: center 35%;
}
[data-part="hero"] {
min-height: 22rem;
}
[data-part="company-name"] {
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.5);
}
3. A restyled Add button (Classic and Hero)
The Add button keeps its job; only its look changes. Keep it visible and at least 44 pixels high, so it stays easy to tap.
[data-part="add-button"] {
background-color: var(--menu-primary, #1d4ed8);
color: var(--menu-on-primary, #ffffff);
border: 0;
border-radius: 9999px;
min-height: 44px;
box-shadow: 0 4px 12px rgba(29, 78, 216, 0.35);
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
[data-part="add-button"]:active {
transform: scale(0.94);
}
[data-part="add-button"]:focus-visible {
outline: 2px solid var(--menu-primary, #1d4ed8);
outline-offset: 2px;
}
[data-part="add-button"]:disabled {
box-shadow: none;
}
The second argument of var() is a fallback, so the same CSS works in Classic, where --menu-primary exists only when a main or brand colour is set.
A small animation
@keyframes menu-fade {
from { opacity: 0; }
to { opacity: 1; }
}
[data-part="category-section"] {
animation: menu-fade 0.4s ease both;
}
Tip: Respect people who prefer less motion. Wrap animations in
@media (prefers-reduced-motion: no-preference) { ... }.
Safe mode: ?nocss=1
Add ?nocss=1 to the address of the menu page (or &nocss=1 if the address already has a ?) and the page opens without your stylesheet. Use it to see the template as it is, to find out whether a problem comes from your CSS, or when support asks you to. It changes nothing for other visitors.
Stability and what the platform may do
- We keep the names stable. The parts and variables listed here for a template keep working, and we will announce a change in advance.
- The platform may switch custom CSS off for a company. Support can do this, for example when a stylesheet makes a page unusable. The editor then shows Custom CSS is switched off for your company by the platform, so customers do not get it. The page keeps working with the template and your settings. Contact support to have it switched back on.
- Publish again after a platform update. The checker is occasionally made stricter. When the editor shows The custom CSS of this menu was made with an older version and is switched off until you publish again, open the tab, fix anything it now flags, and press Publish. Until you do, customers see the page without your CSS.
Troubleshooting
My CSS does nothing.
The preview shows the draft, but customers only see what is published, so check that you pressed Publish. Then compare with ?nocss=1. Make sure the part exists in your template (the tables above); a selector for a missing part matches nothing.
It works for most things, but not the Classic header's background, or the Hero cover.
Those two are written directly on the element when set (the Classic header when a main colour exists, the Hero cover when you chose a cover photo in Settings). For the header, change the variables instead of the background. For the cover, clear the Cover photo setting, or add !important.
The editor says "Addresses (url) are not allowed in the CSS. Use one of your uploaded pictures through its --asset variable." about a picture. (The API calls this CSS_FORBIDDEN_URL.)
You cannot use addresses. Upload the picture in Images and use var(--asset-name).
The picture does not show.
Check that the name in --asset-name is exactly the one on the picture's card, and that you used it in background-image or background. A name with a typo paints nothing.
I get an error on a line that looks fine. Look for an invisible character, often a non-breaking space or a byte order mark from pasted text. Retype the spaces on that line, or paste through a plain-text editor.
I cannot restyle the cart, the waiter button or the notices. This is on purpose. They follow your colours, corners and font through the variables, and no stylesheet can hide or break them.
I changed template and my CSS looks wrong. Parts have different names and structure in each template, so rules written for Classic mostly do not apply to Hero. Rewrite the selectors from the table of the new template.
Can I use a font that is not in the list?
No. @font-face is not allowed. Choose one of the eight fonts in Settings → Fonts.
I want to back up or move my CSS. Export theme includes the custom CSS. Import theme brings it back, into the same or another company, if its plan includes custom CSS.