Module UI: Styling Module Pages
A module can add whole pages to a site: an admin dashboard, a settings screen, a list of records. The problem is that a module author has no idea which theme the site owner will use. If the module writes plain HTML, it looks unstyled next to a fully designed navbar and footer. If it ships its own big stylesheet, it looks foreign on every theme, and every module ends up reinventing forms and tables.
Module UI solves this. It is a small set of ready-made CSS classes (all starting with
rhapsody-) and a base layout that every module page can build on. The classes look decent on any
theme, light or dark, and a theme can fine-tune them if it wants to. This page explains how to use them
from scratch.
The 60-second version
Create a Twig view in your module. Extend the Module UI layout, and put your page inside the module_content block:
{% extends "@core/module-layout.twig" %}
{% block module_content %}
<header class="rhapsody-page-header">
<h1 class="rhapsody-title">Hello from my module</h1>
<p class="rhapsody-muted">This page is styled automatically.</p>
</header>
<div class="rhapsody-card">
<p>Anything you put in here gets the theme's navbar and footer, plus Module UI styling.</p>
<a href="{{ base_url }}/somewhere" class="rhapsody-btn">A button</a>
</div>
{% endblock %}
That is the whole idea. The rest of this page covers what each part does and every class you can use.
How it works
A module page is built from three layers of templates, each one extending the next:
Your module view e.g. @my-module/dashboard.twig
| extends
v
@core/module-layout.twig adds the Module UI stylesheet and a wrapper
| extends
v
layouts/main.twig the site's active theme: navbar, footer, fonts, colors
- Your view only contains the page itself.
@core/module-layout.twigcomes with the framework. It wraps your content in a<div class="rhapsody-ui">container, and adds the baseline stylesheet. Every Module UI rule is scoped to that container, so nothing leaks out and changes the rest of the theme.layouts/main.twigis whatever theme the site owner has chosen. Because your page extends it indirectly, you get that theme's navigation and footer for free, and you never need to know what they look like.
The stylesheet picks its colors from the surrounding text color instead of hard-coding a palette. That is why the same page works on a dark theme and a light theme without any extra work.
Blocks you can use
module_content: your page goes here. This is the one you will use 99% of the time.stylesandscripts: only if your page needs extra CSS or JavaScript. See the warning below.
- Do not override the
contentblock. That block is where the wrapper and the Module UI classes live. Overriding it throws them away. Always usemodule_content. - Always call
{{ parent() }}when you overridestylesorscripts. Without it, the Module UI stylesheet or script is replaced by your block and your page loses its styling or spacing.
{% block scripts %}
{{ parent() }}
<script src="{{ base_url }}/my-module/chart.js"></script>
{% endblock %}
Class reference
Every class below works inside module_content. You never need to write CSS for any of this. Copy the
markup and change the text.
Page header and text
rhapsody-page-header: a block that holds the page title and an optional description.rhapsody-title: the main page heading (use it on your<h1>).rhapsody-section-title: a smaller heading for sections further down the page (use it on<h2>).rhapsody-muted: softer, lower-contrast text, good for descriptions and empty states.rhapsody-list: a bulleted list with proper spacing.
<header class="rhapsody-page-header">
<h1 class="rhapsody-title">Redirects</h1>
<p class="rhapsody-muted">Send visitors from old URLs to new ones.</p>
</header>
<h2 class="rhapsody-section-title">Why use redirects?</h2>
<ul class="rhapsody-list">
<li>They keep old links working.</li>
<li>They preserve search rankings when a page moves.</li>
</ul>
Cards and grids
A card is a bordered, softly shaded box. Use it to group related things. A grid lays several cards side by side, and wraps them onto separate rows on narrow screens, so there is nothing to configure for mobile.
<div class="rhapsody-grid">
<div class="rhapsody-card">
<strong>Total pages</strong>
<p>128</p>
</div>
<div class="rhapsody-card">
<strong>Redirects</strong>
<p>14</p>
</div>
<div class="rhapsody-card">
<strong>404 errors this week</strong>
<p>3</p>
</div>
</div>
Forms
The building blocks of a form:
rhapsody-form: put this on the<form>tag. It stacks the fields with consistent spacing.rhapsody-form--inline: add this too for a compact one-row form (a search box plus a button, for example).rhapsody-field: one label plus one input, grouped together.rhapsody-label: the label text.rhapsody-input,rhapsody-select,rhapsody-textarea: the form controls themselves.rhapsody-help: small grey hint text under a field.rhapsody-error: red error text under a field.rhapsody-field--error: add this to a field whose value failed validation. The input gets a red border.
Here is a complete form, including showing validation errors. The errors and old variables are the same ones the framework's own login and registration forms use (see the Validation docs):
<form class="rhapsody-form" method="post" action="{{ base_url }}/my-module/settings">
{{ csrf_field()|raw }}
<div class="rhapsody-field {{ errors.site_name is defined ? 'rhapsody-field--error' : '' }}">
<label class="rhapsody-label" for="site_name">Site name</label>
<input type="text" class="rhapsody-input" id="site_name" name="site_name"
value="{{ old.site_name|default(site_name) }}">
<span class="rhapsody-help">Shown in the browser tab.</span>
{% if errors.site_name is defined %}
<span class="rhapsody-error">{{ errors.site_name|first }}</span>
{% endif %}
</div>
<div class="rhapsody-field">
<label class="rhapsody-label" for="mode">Mode</label>
<select class="rhapsody-select" id="mode" name="mode">
<option value="live">Live</option>
<option value="test">Test</option>
</select>
</div>
<div class="rhapsody-field">
<label class="rhapsody-label" for="notes">Notes</label>
<textarea class="rhapsody-textarea" id="notes" name="notes">{{ old.notes|default('') }}</textarea>
</div>
<div class="rhapsody-actions">
<button type="submit" class="rhapsody-btn">Save</button>
</div>
</form>
{{ csrf_field()|raw }} inside it, exactly as
in the example. Without it, the request is rejected. Always use {{ base_url }} at the start of links and
form actions, so your module keeps working if the site lives in a sub-folder.
For a one-row form, add rhapsody-form--inline. Fields can then sit directly in the form without rhapsody-field wrappers:
<form class="rhapsody-form rhapsody-form--inline" method="post" action="{{ base_url }}/my-module/search">
<input type="text" class="rhapsody-input" name="q" placeholder="Search...">
<button type="submit" class="rhapsody-btn">Search</button>
</form>
Buttons
rhapsody-btn: the main button, in the accent color.rhapsody-btn--secondary: a quieter outlined button (add it next torhapsody-btn).rhapsody-btn--danger: a red button for destructive actions such as Delete.rhapsody-btn--sm: a smaller button, good inside table rows.rhapsody-actions: put buttons inside this to line them up in a row with a gap between them.
Modifier classes are always combined with the base class. Links work as buttons too:
<div class="rhapsody-actions">
<button type="submit" class="rhapsody-btn">Save</button>
<a href="{{ base_url }}/my-module" class="rhapsody-btn rhapsody-btn--secondary">Cancel</a>
<button type="submit" class="rhapsody-btn rhapsody-btn--danger rhapsody-btn--sm">Delete</button>
</div>
Tables
Wrap every table in rhapsody-table-wrap. It lets wide tables scroll sideways on small screens instead of
breaking the page layout. Put rhapsody-table on the table itself. To draw attention to a cell (a value that is
too long, a missing field), add rhapsody-warn to it. The cell is highlighted with a tint and bold text, so it
stays readable on any theme and does not rely on color alone.
<div class="rhapsody-table-wrap">
<table class="rhapsody-table">
<thead>
<tr><th>Page</th><th>Title length</th><th></th></tr>
</thead>
<tbody>
{% for page in pages %}
<tr>
<td>{{ page.path }}</td>
<td class="{{ page.title_length > 60 ? 'rhapsody-warn' : '' }}">{{ page.title_length }}</td>
<td>
<form class="rhapsody-form rhapsody-form--inline" method="post"
action="{{ base_url }}/my-module/pages/{{ page.id }}/delete">
{{ csrf_field()|raw }}
<button type="submit" class="rhapsody-btn rhapsody-btn--danger rhapsody-btn--sm">Delete</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="3" class="rhapsody-muted">Nothing here yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
The {% else %} branch of the {% for %} loop runs when the list is empty. It is a good habit to always provide one, so a new install does not show a blank table.
Alerts and badges
rhapsody-alertplus one ofrhapsody-alert--info,rhapsody-alert--success,rhapsody-alert--warningorrhapsody-alert--error: a colored message box.rhapsody-badge: a small pill-shaped label, good for statuses like "Active" or "Pending".
<div class="rhapsody-alert rhapsody-alert--success">Your settings were saved.</div>
<div class="rhapsody-alert rhapsody-alert--error">Something went wrong. Please try again.</div>
<p>Status: <span class="rhapsody-badge">Active</span></p>
Inline <code> tags inside a Module UI page are styled automatically as well.
A complete example
Here is a full page for an imaginary "Notes" module, using almost everything above. It shows a form to add a
note, then a table of existing notes. Assume your controller passes in a notes list, and (after a failed
form submission) errors and old.
{% extends "@core/module-layout.twig" %}
{% block module_content %}
<header class="rhapsody-page-header">
<h1 class="rhapsody-title">Notes</h1>
<p class="rhapsody-muted">Short notes your team can see.</p>
</header>
<div class="rhapsody-card">
<h2 class="rhapsody-section-title">Add a note</h2>
<form class="rhapsody-form" method="post" action="{{ base_url }}/notes">
{{ csrf_field()|raw }}
<div class="rhapsody-field {{ errors.body is defined ? 'rhapsody-field--error' : '' }}">
<label class="rhapsody-label" for="body">Note</label>
<textarea class="rhapsody-textarea" id="body" name="body">{{ old.body|default('') }}</textarea>
{% if errors.body is defined %}
<span class="rhapsody-error">{{ errors.body|first }}</span>
{% endif %}
</div>
<div class="rhapsody-actions">
<button type="submit" class="rhapsody-btn">Add note</button>
</div>
</form>
</div>
<h2 class="rhapsody-section-title">All notes</h2>
<div class="rhapsody-table-wrap">
<table class="rhapsody-table">
<thead><tr><th>Note</th><th>Status</th><th></th></tr></thead>
<tbody>
{% for note in notes %}
<tr>
<td>{{ note.body }}</td>
<td><span class="rhapsody-badge">{{ note.status }}</span></td>
<td>
<form class="rhapsody-form rhapsody-form--inline" method="post"
action="{{ base_url }}/notes/{{ note.id }}/delete">
{{ csrf_field()|raw }}
<button type="submit" class="rhapsody-btn rhapsody-btn--danger rhapsody-btn--sm">Delete</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="3" class="rhapsody-muted">No notes yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
For how your module's controller or route handler actually renders a view like this one, see the Modules documentation.
Automatic spacing around menus
Themes position their navigation in all kinds of ways: a bar fixed to the top of the screen, a menu down the left side (very common in admin-style themes), a floating pill, or something else entirely. If a menu is fixed to the screen, it floats on top of the page, and content that starts at the very top of the page would be hidden underneath it.
Module UI handles this for you. A tiny script (loaded by @core/module-layout.twig) measures the
page, finds any fixed menu that actually overlaps your content, and adds just enough padding to keep your page
clear of it. It does not assume what kind of menu there is or how big it is.
- A wide, short bar at the top of the screen gets space added above your content.
- A tall, narrow menu on the left or right edge gets space added on that side.
- If the theme already leaves room for its menu, nothing extra is added.
- Menus that slide in and out (such as a mobile drawer), or collapse, are re-measured automatically when the window is resized or the menu animates.
- Things that are not navigation are ignored: cookie banners at the bottom, decorative backgrounds, and chat bubbles.
If a theme has an unusual menu that is not detected correctly, the theme can set the space explicitly. See Customizing the look below.
Customizing the look (for theme authors)
Out of the box, Module UI works on any theme, and there is nothing you must do. If you would like module pages to
match your theme more closely, set any of these CSS variables on :root in your theme's stylesheet. All of
them are optional, and anything you leave out keeps its sensible default.
--rhapsody-primary: the accent color, used for the main button, focus outlines and info alerts. Default: a violet-blue.--rhapsody-primary-contrast: the text color shown on top of the accent color. Default: white.--rhapsody-danger,--rhapsody-success,--rhapsody-warning: the red, green and amber used for errors, success messages and highlighted table cells.--rhapsody-radius: how rounded the corners are on cards, inputs and buttons. Default:0.5rem.--rhapsody-gap: the spacing between blocks. Default:1rem.--rhapsody-border: the color of borders and dividers.--rhapsody-surface: the background of cards and badges.--rhapsody-inset-top,--rhapsody-inset-left,--rhapsody-inset-right: space to reserve for a fixed menu. You normally do not need these, because the spacing is measured automatically. Use them only to override the automatic result. The larger of the two values wins.
For example, a theme with a gold accent and square corners might add this to its main stylesheet:
:root {
--rhapsody-primary: #c9a227;
--rhapsody-primary-contrast: #1a1a1a;
--rhapsody-radius: 2px;
}
The default border and card colors are worked out from the text color, so they already suit both light and dark
themes. Most themes only need to set --rhapsody-primary to feel at home.
Common mistakes
- Using the theme's own classes. Classes like Tailwind's
bg-blue-500only exist if the active theme provides them. A module cannot know that. Stick torhapsody-*classes and your page works everywhere. - Putting Module UI classes outside
module_content. The styles only apply inside therhapsody-uiwrapper, whichmodule_contentsits in. - Overriding
contentinstead ofmodule_content. This removes the wrapper and the styling. - Forgetting
{{ parent() }}when overridingstylesorscripts. - Hard-coding URLs such as
/my-module/save. Use{{ base_url }}/my-module/saveso your module works when the site is installed in a sub-folder. - Forgetting
{{ csrf_field()|raw }}inside a form.
Related documentation
- Modules & Extensibility: how to build a module and render its views.
- Creating custom themes: the blocks every theme layout must provide.
- Views & Templating: Twig basics, inheritance and passing data.
- Validation: where the
errorsandoldvariables come from.