LearnNewsExamplesServices

Styling and Theming in Neo.mjs

This guide provides a comprehensive overview of how to style your Neo.mjs applications, from basic component styling to creating entirely new themes.

The Core Concepts

Styling in Neo.mjs is a layered system designed for both performance and flexibility. It combines component-level styles, a powerful SCSS-based theme engine, and a lazy-loading mechanism to ensure that only the necessary CSS is loaded at any given time.

Here are the key pillars of the styling system:

  1. Component-Based Styles: Applying styles directly to component instances.
  2. VDOM-Based Styles: How component styles are translated into the virtual DOM.
  3. SCSS & Theming: The structure of the SCSS source files and how themes are built.
  4. Theme Inheritance: How themes can extend and override base styles.
  5. Build Process: The scripts used to compile SCSS into CSS.
  6. Lazy Loading: How the engine efficiently loads theme styles on demand.

Let's dive into each of these areas.

1. Component-Based Styles

The primary way to style individual components is through configuration properties on the component itself. The Neo.component.Base class provides several key configs for this purpose.

style

For applying inline CSS styles, use the style config. It accepts a standard JavaScript object. While the recommended convention is to use camelCased keys for CSS properties, you can also use quoted kebab-case keys.

// Recommended: camelCase
{
    ntype: 'button',
    text: 'Click Me',
    style: {
        backgroundColor: 'blue',
        color: 'white',
        borderRadius: '5px'
    }
}

// Also valid: quoted kebab-case
{
    ntype: 'button',
    text: 'Click Me',
    style: {
        'background-color': 'blue',
        'color': 'white',
        'border-radius': '5px'
    }
}

This will be rendered as style="background-color: blue; color: white; border-radius: 5px;" on the component's main HTML element.

cls

To apply CSS classes, use the cls config, which accepts an array of strings.

{
    ntype: 'panel',
    cls: ['my-custom-panel', 'another-class']
}

Neo.mjs will automatically add its own classes for theming and functionality, so your custom classes will be merged with the engine's classes.

ui

The ui config is a special styling hook that provides a simple way to apply a predefined set of styles to a component. It works by adding a CSS class in the format neo-<ntype>-<ui>.

For example, if you have a button:

{
    ntype: 'button',
    text: 'Important Button',
    ui: 'primary'
}

This will add the class neo-button-primary to the button's element, allowing you to target it with specific styles in your theme.

Implementing a Custom ui Style

To create your own ui variant, you simply add the corresponding CSS rule to your theme's SCSS file. For example, to create a "success" button style, you would first set the config:

{
    ntype: 'button',
    text: 'Save Changes',
    ui: 'success'
}

Then, in your theme's SCSS file (e.g., resources/scss/theme-my-theme/button/Base.scss), you would add the style definition:

.neo-button-success {
    background-color: #28a745; // Green for success
    color: white;
    // any other styles...
}

Advanced Styling: Wrappers and Root Nodes

For more advanced control, Neo.component.Base provides a set of five style and class-related configs. Understanding them requires understanding the difference between a component's outermost node and its logical root node (getVdomRoot()).

  • For many simple components, these two nodes are the same.
  • For complex components, the logical root might be a child of the outermost node. For example, a grid cell component's logical root might be a <div>, but its outermost node is the <td> that wraps it.

This distinction explains the purpose of the different configs:

  • style: An object of inline styles applied to the component's logical root node.
  • cls: An array of CSS classes applied to the component's logical root node.
  • wrapperStyle: An object of inline styles applied to the component's outermost node. This is only needed when the outermost node is different from the logical root.
  • wrapperCls: An array of CSS classes applied to the component's outermost node, for the same reason as wrapperStyle.
  • baseCls: An array of fundamental CSS classes applied by the component class itself for its core functionality. This is for internal engine use and is automatically merged into the final cls array.

Best Practice: cls vs. style

Whenever possible, it is considered best practice to use cls instead of style. Defining styles in CSS classes keeps your component definitions cleaner and makes your styles more reusable and maintainable.

The style config should be reserved for situations where style properties are being calculated dynamically at runtime and are specific to that single component instance. A perfect example is a resizable Dialog component. As a user drags the corner of the dialog, the engine will dynamically update its width and height via the style config. These are transient, calculated values that don't belong in a reusable CSS class.

2. VDOM-Based Styles

All component configurations, including style and cls, are ultimately applied to the component's Virtual DOM (VDOM) tree. The engine then efficiently updates the real DOM based on changes to the VDOM.

When you change a style-related config at runtime, the component's afterSet hook for that config (e.g., afterSetStyle()) is triggered. This hook updates the VDOM, and the engine's rendering pipeline applies the changes to the live DOM. This reactive system ensures that UI updates are fast and automatic.

Where to Apply Styles: A Critical Distinction

To avoid conflicts and ensure the reactive system works correctly, it is critical to follow this rule:

  • For the component's root VDOM node(s): Always use the component-level configs (cls, style, wrapperCls, wrapperStyle). Do not add cls or style attributes directly to the root node within the vdom object itself. This allows the engine to manage these styles reactively. If you set them directly on the VDOM root, your styles could be overwritten by a config change, or they could conflict with it.

  • For all other descendant VDOM nodes: Use the standard inline cls (as an array) and style (as an object) attributes directly inside the VDOM structure. This is the correct and intended way to style the inner parts of your component.

// GOOD EXAMPLE
{
    ntype: 'container',
    // Use component configs for the root node
    cls: ['my-container'],
    style: { border: '1px solid blue' },

    // Define VDOM with inline styles for descendants
    vdom: {
        // No cls or style here!
        cn: [{
            tag: 'h1',
            cls: ['my-title'], // Correct for a descendant
            style: { color: 'blue' }, // Correct for a descendant
            text: 'My Component'
        }, {
            // ... other descendant nodes
        }]
    }
}

3. SCSS & Theming

Neo.mjs's theming system is built with SCSS. The source files are located in the resources/scss directory.

The structure is typically:

  • resources/scss/src/: Contains the base structural styles for all components. These are the un-themed, core styles.
  • resources/scss/theme-light/: Contains the styles for the light theme.
  • resources/scss/theme-dark/: Contains the styles for the dark theme.

Within each of these folders, the SCSS files are organized to mirror the component's namespace in the src directory. For example, the styles for Neo.button.Base would be located at:

  • resources/scss/src/button/Base.scss
  • resources/scss/theme-light/button/Base.scss
  • resources/scss/theme-dark/button/Base.scss

4. SCSS File & Namespace Mapping

For the automatic lazy-loading of theme files to work, it is critical that the path of an SCSS file mirrors the namespace of the JavaScript class it styles. The build process uses this convention to generate the theme-map.json.

Framework Components

For standard engine components, the mapping is direct. The path within resources/scss/src (or a theme folder) matches the class path after Neo..

  • JS Class: src/button/Base.mjs (which defines Neo.button.Base)
  • Maps to SCSS: resources/scss/src/button/Base.scss

Application Components (The view rule)

Applications follow a similar rule, but with one important exception: the view folder in the JavaScript path is omitted from the SCSS path. It is a mandatory convention that only components inside an application's view folder should have associated SCSS files.

  • JS Class: apps/portal/view/Viewport.mjs (defines Portal.view.Viewport)
  • Maps to SCSS: resources/scss/src/apps/portal/Viewport.scss

Notice how view/ is not present in the SCSS path. The engine's build tools and runtime loader are specifically coded to handle this convention. Adhering to it is essential for your application's styles to be loaded correctly.

4. SCSS File & Namespace Mapping

For the automatic lazy-loading of theme files to work, it is critical that the path of an SCSS file mirrors the namespace of the JavaScript class it styles. The build process uses this convention to generate the theme-map.json.

Framework Components

For standard engine components, the mapping is direct. The path within resources/scss/src (or a theme folder) matches the class path after Neo..

  • JS Class: src/button/Base.mjs (which defines Neo.button.Base)
  • Maps to SCSS: resources/scss/src/button/Base.scss

Application Components (The view rule)

Applications follow a similar rule, but with one important exception: the view folder in the JavaScript path is omitted from the SCSS path. It is a mandatory convention that only components inside an application's view folder should have associated SCSS files.

  • JS Class: apps/portal/view/Viewport.mjs (defines Portal.view.Viewport)
  • Maps to SCSS: resources/scss/src/apps/portal/Viewport.scss

Notice how view/ is not present in the SCSS path. The engine's build tools and runtime loader are specifically coded to handle this convention. Adhering to it is essential for your application's styles to be loaded correctly.

5. Theme Inheritance

The theming engine uses a powerful and automatic inheritance model. You do not need to manually @import base styles into your theme's SCSS files. The engine handles this for you at runtime.

Here's how it works: When a component is created, the insertThemeFiles() method (in src/worker/App.mjs) inspects the component's entire JavaScript prototype chain. It walks up the chain from the component's class (e.g., MyApp.view.CustomButton) through its parents (like Neo.button.Base, Neo.component.Base, etc.) and loads the corresponding CSS file for each class that has one.

This means that the styles from src are always loaded as the base, and your theme's styles are automatically applied on top of them as overrides.

This approach has two major benefits:

  1. Simplicity: Your theme files only need to contain the specific styles you want to change. You don't need to worry about managing complex SCSS imports.
  2. Accuracy: The CSS inheritance perfectly mirrors the JavaScript class inheritance.

A theme file can therefore be very clean and focused:

// resources/scss/theme-dark/button/Base.scss

// No @import needed!

.neo-button {
    // Dark theme overrides
    background-color: var(--dark-button-background-color);
    color: var(--dark-button-text-color);
}

You can also create your own themes that inherit from the existing Neo.mjs themes. The same principle applies: the engine will load the base theme's CSS first, followed by your new theme's CSS.

6. Architecting Nestable Themes

A key feature of the Neo.mjs theming architecture is the ability to nest components with different themes inside each other. For example, you could have a dark-themed grid inside a light-themed panel. To make this work reliably, themes must follow a strict separation of concerns.

The Golden Rule:

  • resources/scss/src/ defines structure: The SCSS files in the src directory should define all the CSS selectors and structural properties (like display, position, overflow, etc.). They use CSS variables (var()) for all cosmetic properties (like color, background-color, border, etc.).
  • resources/scss/theme-*/ defines the skin: Theme files should, ideally, only contain definitions for CSS variables. They should not introduce new selectors or override structural properties.

A Practical Example: list/Base.scss

This principle is clearly demonstrated in the styling for Neo.list.Base.

Structure (src/list/Base.scss):

.neo-list-wrapper {
    background-color: var(--list-container-background-color);
    border          : var(--list-container-border);
    overflow        : hidden;
    position        : relative;
}

.neo-list-item {
    background-color: var(--list-item-background-color);
    padding         : var(--list-item-padding);
    /* ... more structural styles */
}

This file sets up the rules. It says that a .neo-list-wrapper can have a border, and its value is determined by the --list-container-border variable.

Skin (theme-dark/list/Base.scss):

:root .neo-theme-dark {
    --list-container-border: 1px solid #282829;
    /* ... other dark theme variables */
}

The dark theme provides a value for the border variable.

Skin & Nullification (theme-light/list/Base.scss):

:root .neo-theme-light {
    --list-container-border: 0;
    /* ... other light theme variables */
}

The light theme explicitly sets the border variable to 0. This is called nullification. It's critical for nesting. If a light-themed list is placed inside a dark-themed component, this rule ensures the list does not incorrectly inherit the dark theme's 1px border. It actively resets the property defined in the src structure.

Bad Practice & The Right Way Forward

While you technically can add new selectors or structural overrides inside a theme file, it is considered bad practice if you want your theme to be nestable and composable with other themes. Doing so can lead to unpredictable side effects when themes are mixed and matched.

What if a needed selector doesn't exist in src?

There might be cases where your custom design requires styling a part of a component that doesn't have a dedicated CSS class or selector in the base src files. Here is the recommended workflow:

  1. Temporarily add the selector to your theme: To keep your project moving, it is acceptable to add the new structural selector directly into your theme's SCSS file as a temporary measure.
  2. Open a feature request: Immediately after, you should open a feature request ticket in the Neo.mjs GitHub repository. The ticket should describe the component you are styling and the new selector(s) you need.

This process allows you to continue your work without being blocked, while also contributing back to the engine. Once the new selectors are added to the src files in a future Neo.mjs update, you can refactor your theme to remove the temporary structural code and use the new, official CSS variables instead. This keeps themes clean and aligned with the engine's architecture for the long term.

If you are creating a one-off, custom theme that will never be nested, this rule is less critical. However, for creating robust, reusable themes, sticking to the "structure vs. skin" separation is essential.

7. Creating a New Theme

The most efficient and recommended way to create a new theme is to start with an existing one. This ensures you have all the necessary folder structures and CSS variable definitions in place.

  1. Choose a Base Theme and Duplicate It: Decide whether theme-light or theme-dark is a closer starting point for your design. Then, duplicate the folder and give it a new name.

        # Example: Creating a new theme based on theme-light
    cp -r resources/scss/theme-light resources/scss/theme-my-awesome-theme
  2. Customize Your Theme's Variables: Now you can go through the .scss files in your new theme-my-awesome-theme folder and adjust the CSS variable values to match your design requirements.

  3. Configure Your Application: In your neo-config.json, add your new theme to the themes array. The first theme in the array becomes the default theme for the application.

        {
        "themes": ["neo-theme-my-awesome-theme", "neo-theme-light"]
    }

    Note: The neo- prefix is required, and your folder name should not include it (e.g., folder theme-foo becomes neo-theme-foo in the config).

  4. Build Your New Theme: Run the full theme build process to compile the SCSS for your new theme and, critically, to update the theme-map.json file to include it.

        npm run build-themes

8. Theming in a Workspace

While the concepts above apply everywhere, it's important to understand where you should place your custom theme files when developing your own application. For this, Neo.mjs uses a workspace structure, typically created with npx neo-app.

A workspace mirrors the main neo.mjs repository structure, including its own resources/scss directory. This allows you to add themes and styles for your custom applications without modifying the engine's source code (which is included as an npm dependency in node_modules).

The SCSS Merge Mechanism

The most powerful feature of workspace theming is how the build scripts work. When you run npm run build-themes from your workspace, the script intelligently merges the SCSS files from your workspace's resources/scss directory with the ones from node_modules/neo.mjs/resources/scss.

The workspace's files act as an overlay, giving you fine-grained control.

This enables several powerful workflows:

  1. Override Specific Variables: To change just a few variables for an existing theme (e.g., theme-dark), you only need to create a file at the corresponding path in your workspace (e.g., my-workspace/resources/scss/theme-dark/button/Base.scss) and redefine only the variables you want to change. The build script will merge your changes with the original theme file from the engine.

  2. Create an Entirely New Theme: You can create a brand new theme folder (e.g., my-workspace/resources/scss/theme-corporate) inside your workspace. By creating SCSS files that match the paths of the framework components, you can provide a complete set of CSS variable definitions for your theme. The build script will discover and compile your new theme, allowing you to build a unique look and feel from the ground up without ever touching the engine's source code.

  3. Style App-Specific Components: If you create a component that is only used within a single application (e.g., my-workspace/apps/my-app/view/MyComponent.mjs), you can create its structural styles in your workspace at my-workspace/resources/scss/src/apps/my-app/MyComponent.scss. The build script will pick it up and process it just like a core component.

  4. Style Workspace-Shared Components: For components intended to be shared across multiple apps in your workspace, you can create them in the workspace's main src folder. These components must use the Neo namespace (e.g., my-workspace/src/component/MyWorkspaceWidget.mjs defining Neo.component.MyWorkspaceWidget). You can then provide their structural styles in the corresponding path within your workspace's resources/scss/src folder (e.g., my-workspace/resources/scss/src/component/MyWorkspaceWidget.scss).

This overlay approach is extremely powerful. It lets you maintain a clean separation between your application code and the framework, making engine upgrades significantly easier.

9. The Build Process

To compile the SCSS files into the CSS that the browser uses, Neo.mjs provides two main build scripts.

build-themes

The npm run build-themes command is the main script for a full theme build. It:

  1. Compiles all .scss files in resources/scss into .css files.
  2. Uses postcss to add vendor prefixes (autoprefixer) and minify the CSS (cssnano) for production builds.
  3. Places the output into the dist/<environment>/css/ directory.
  4. Generates a critical file: resources/theme-map.json.

The theme-map.json file creates a mapping between every class name and the themes that have custom styles for it. This file is the key to the lazy loading mechanism.

watch-themes

For development, you can use npm run watch-themes. This script will watch the resources/scss directory for any changes and recompile only the file that was changed. This provides a much faster feedback loop when you are developing themes.

Important Note: The current version of watch-themes only handles changes to existing files. It does not detect new files, renamed files, or deleted files. As a result, if you add, move, or delete SCSS files while the watcher is running, the theme-map.json will not be updated, which can lead to inconsistencies. To apply these kinds of changes, you can run a full npm run build-themes command in a separate terminal. Enhancing the watch script to handle these cases is a planned improvement.

9. Lazy Loading in Action

You do not need to manually include any theme CSS files in your application's index.html. The engine handles it automatically.

Here's how it works:

  1. When the application starts, the worker.App loads the theme-map.json file.
  2. When a component is about to be created, the engine checks the theme-map.json to see if the active theme has a specific CSS file for that component.
  3. If it does, it sends a message to the main.addon.Stylesheet (in the main thread) to dynamically create a <link> tag for that CSS file and add it to the document's <head>.
  4. The browser then loads the CSS file.

This process ensures that you only ever load the CSS that is actually needed for the components currently in your application, which can significantly improve initial load times.

VDOM Updates and Style Loading

The lazy loading of styles is tightly integrated with the rendering engine to prevent a "flash of unstyled content" (FOUC) and unnecessary layout recalculations.

Imagine you are showing a complex component, like a grid, for the first time. The engine will trigger the lazy loading of the grid's theme CSS. If a VDOM update for the grid were to proceed immediately, the browser might render the grid's DOM structure before its styles have arrived, causing a flicker or a jarring layout shift once the styles are applied.

To prevent this, the updateVdom() method in src/mixin/VdomLifecycle.mjs contains a crucial check. It looks at the Neo.worker.App instance to see if any theme files are currently being loaded (countLoadingThemeFiles > 0). If they are, it will pause the VDOM update for the component and listen for a themeFilesLoaded event. Once all pending CSS files have been loaded, the VDOM update is automatically resumed.

This elegant mechanism ensures that a component's DOM is only mounted or updated after its required styles are in place, leading to a smoother and more professional user experience.