Theme Structure
theme.json and css/style.css are the technical floor — with just those two, the theme cascade already works, falling back to theme/default/ for anything else. But to actually appear as selectable in Admin → Appearance → Themes, a theme also needs header.php, footer.php, and home.php: the theme manager's own folder scan skips any theme missing one of those four files, full stop. So in practice, the real minimum is all five: theme.json, css/style.css, header.php, footer.php, home.php. Every other file is optional beyond that — a missing template silently falls back to theme/default/, so your CSS loads against someone else's HTML until you supply your own version of that file.
You don't have to write that floor by hand: theme/starter/ ships with exactly this set (plus content-articles.php, content-pages.php, content-projects.php, content-list.php, and 404.php) as a working, minimal theme you can copy and rename.
A site owner can register custom content types beyond Article/Page/Project from the admin (see Content Model Reference) without any theme change being required. If your theme has no content-{type}s.php for that type, it renders through theme/default/content-generic.php instead — a generic template covering title, date, tags, category, featured image, custom fields, and body content. Ship a dedicated content-{type}s.php only if the generic fallback doesn't match your theme's design.
theme/my-theme/
│
├── theme.json ← REQUIRED — manifest
├── functions.php ← Optional — theme-specific PHP helpers
│
├── css/
│ ├── style.css ← REQUIRED — must be named exactly style.css
│ ├── contact.css ← Optional — contact form styles
│ └── shortcodes.css ← Optional — CMS shortcode styles
│
├── js/
│ └── script.js ← Optional — must be named exactly script.js
│
├── header.php ← Page opening (<!DOCTYPE> → <main>)
├── footer.php ← Page closing (</main> → </html>)
├── home.php ← Homepage template
├── 404.php ← 404 error template
│
├── content-articles.php ← Single article view
├── content-projects.php ← Single project view
├── content-pages.php ← Single static page view
├── content-{type}s.php ← Optional — single view for a site owner's custom content type
├── content-list.php ← List view (articles/projects/categories/tags, and any custom type)
│
├── partials/
│ ├── article-card.php ← Article card HTML override
│ └── project-card.php ← Project card HTML override
│
├── page-templates/ ← Custom page templates (selectable per page in admin)
│ ├── contact.php
│ └── landing.php
│
└── preview.jpg ← screenshot for the theme manager (preview.jpg/.jpeg/.png/.webp)
theme.json
{
"synaptik_theme": true,
"name": "My Theme",
"folder": "my-theme",
"version": "1.0.0",
"description": "A minimal theme for Synaptik CMS.",
"author": "Your Name",
"author_url": "https://example.com"
}
"synaptik_theme": true is required — it's how the CMS tells a real theme folder apart from anything else placed under /theme/, and what the ZIP installer checks before accepting an upload.
folder matters more than it looks: when a theme is installed from a ZIP, this — not the ZIP's internal folder structure — is what determines the destination directory name under /theme/. Set it explicitly and never set it to default; that folder belongs to the bundled fallback theme and isn't protected from being overwritten on a core update. (There's no screenshot field — the preview image is picked up purely by filename, see the tree above.)
Optional fields used only if you intend to distribute updates through the CMS's own update checker: download_url, info_url, demo_url (matching the pattern used by bundled themes like Ink or Vitae).
The theme's folder name (the folder field above, or the destination folder for a manually-copied theme) becomes its identifier in config.json's active_theme. Never name it default; that folder belongs to the bundled fallback theme and isn't protected from being overwritten on a core update.
Asset naming conventions
| Asset | Required name | Path |
|---|---|---|
| Main CSS | style.css | theme/{name}/css/style.css |
| Theme JS | script.js | theme/{name}/js/script.js |
Both are detected and injected automatically by render_header_scripts(). Do not hardcode <link>/<script> tags for them in your templates — that produces duplicate loads.
Light/dark logo variants
If your theme has a dark/light mode toggle, render_site_logo() (called from your header.php) can swap the site logo automatically between modes — with no extra code in your theme.
It works purely from file naming: if the site's configured logo is files/logo.webp, and both files/logo-light.webp and files/logo-dark.webp also exist (uploaded via Admin → File Manager), both <img> tags are rendered and toggled with a small inline <style> block based on the [data-theme] attribute your toggle sets on <html>. Pure CSS, no flash, no theme code required. If either variant is missing, it silently falls back to rendering the single configured logo as before.
The only thing your theme needs to get this "for free" is to already set [data-theme="dark"] / [data-theme="light"] on <html> for its mode toggle — the same convention used by the bundled Atrium, Ink, Prism, Vitae, and Synaptik-Docs themes. If your theme uses a different attribute or a body class instead, this feature won't trigger (the fallback single-logo behavior still works fine).
See render_site_logo() for the full behavior.
Customizing a theme without losing changes on update
Editing a theme's files directly is fine until it gets updated — the update overwrites everything it ships. See Theme Customizations for theme/child_theme/, an override folder theme updates never touch.
Installing a theme
Manual: create theme/my-theme/ with theme.json, css/style.css, header.php, footer.php, home.php — all five, not just the first two, or it won't appear in the list (see above). It appears automatically in Admin → Appearance → Themes once those five files exist. Add the remaining templates progressively — the CMS falls back to theme/default/ for anything still missing.
ZIP upload: zip the theme however you like — the installer locates theme.json inside the archive automatically, so it works whether the ZIP wraps everything in a folder or has the files loose at its root. What determines the installed folder's name is the folder key in theme.json, not the ZIP's structure. Then Admin → Appearance → Import Theme.
Pre-publish checklist
[ ] theme.json present with "synaptik_theme": true and a "folder" key that isn't "default"
[ ] css/style.css present — named exactly style.css
[ ] header.php, footer.php, home.php present
[ ] preview.jpg, preview.jpeg, preview.png, or preview.webp present
[ ] header.php calls render_header_scripts($headerScripts)
[ ] footer.php calls render_search_ui() before </body>
[ ] CSS defines .articles-grid, .article-card, .projects-grid, .project-card
[ ] All UI strings use __t()
[ ] Theme works without partials/ and without content-list.php (both optional)
[ ] Folder name is not "default"
[ ] Tested on mobile
