> ## Documentation Index
>
> Fetch the complete documentation index at: https://superpowers.frontis.nl/llms.txt
> Use this file to discover all available pages before exploring further.

---

title: Umbraco Frontend development
summary: Frontis frontend guidance for coding agents working on the Vite + TypeScript + Vue 3 + Tailwind CSS 4 frontend inside the Web project of a Frontis Umbraco base project, covering build tooling, the DynamicLoader, plugins, Vue components, styling and verification.
order: 60

---

# Frontend development

Use this guide when working on the frontend of a Frontis Umbraco project.

This document is written for coding agents. It explains how to make safe, consistent frontend changes in projects created from the Frontis Umbraco base project (`dotnet new frontisumbraco`).

It adds frontend-specific rules on top of the Umbraco development document. When they conflict, the Umbraco document wins for backend/server concerns and this document wins for frontend concerns.

## When this guide applies

This guide applies to the frontend that lives **inside the Web project** of a Frontis Umbraco project:

```text
src/{App}.Web/
  src/            # frontend sources (TypeScript, CSS, fonts)
  vite.config.ts
  package.json
  eslint.config.js
  tsconfig.json
  .prettierrc
```

Do NOT apply this guide to:

- standalone Nuxt or SPA projects;
- the Umbraco backoffice UI (`App_Plugins`, Lit/Web Components) — that is a different extension model;
- ABP Angular frontends.

The frontend is server-rendered Razor (`.cshtml`) progressively enhanced with TypeScript plugins and selectively hydrated Vue 3 islands. It is NOT a single-page application.

## Source-of-truth priority

Use this priority order:

1. explicit user instruction;
2. local repository files (`AGENTS.md`, `.frontis/*`, existing `src/js/plugins/*`);
3. this document;
4. existing code style in the repository;
5. general frontend knowledge.

Read the existing plugins and helpers before writing new ones — they are the canonical pattern. Several plugins ship a `README.md` (for example `src/js/plugins/modal/README.md`) describing their markup contract and API.

Never invent Frontis frontend conventions from memory.

## Tech stack

The frontend uses:

```text
Vite 8           bundler + dev server
TypeScript       strict, ESM, bundler module resolution
Vue 3            optional islands, lazy-mounted
Tailwind CSS 4   utility-first styling (@tailwindcss/vite)
ESLint 9         flat config, TypeScript + Vue + Prettier
Prettier         no semicolons, single quotes, no trailing comma
Font Awesome     Pro icons (light/regular) + brands
Node 24
```

Key properties:

- ESM only (`"type": "module"`); strict TypeScript with `noUnusedLocals`/`noUnusedParameters`;
- path aliases configured in both `vite.config.ts` and `tsconfig.json`:

```text
@      -> src
@css   -> src/css
@js    -> src/js
@fonts -> src/assets/fonts
```

- a single entry point `src/js/main.ts` produces `wwwroot/dist/bundle-main-[hash].js`;
- Tailwind v4 is configured in CSS (`src/css/main.css`), not in a JS config file.

Do not introduce a different bundler, a global state library, a router, or a second entry point without approval. Do not turn the site into an SPA.

## Project structure

```text
src/
  assets/
    fonts/
  css/
    main.css            # Tailwind import, theme, base, utilities
  js/
    main.ts             # entry point
    dynamic/            # the DynamicLoader (lazy plugin/component loader)
      core/
      helpers/
      constants/
      index.ts
    helpers/            # shared utilities (DOM, state, focus, animation)
    plugins/            # one folder per plugin or Vue island
      modal/
      offcanvas/
      dropdown/
      ...
  vite-env.d.ts
```

Rules:

- shared, reusable utilities go in `src/js/helpers/`;
- each behaviour gets its own folder under `src/js/plugins/{name}/`;
- never put feature logic directly in `main.ts` — `main.ts` only bootstraps the `DynamicLoader` and a few global concerns.

## How the frontend boots

`src/js/main.ts` is intentionally tiny:

```ts
import '@css/main.css'
import { DynamicLoader } from './dynamic/core/DynamicLoader'
import { getScrollBarWidth } from './helpers/scrollbarWidth'

async function initializeUI(): Promise<void> {
  try {
    DynamicLoader.setup()
    getScrollBarWidth()
  } catch (error) {
    console.error('Failed to initialize UI components:', error)
  }
}

document.addEventListener('DOMContentLoaded', () => initializeUI())
```

It imports the CSS, runs the `DynamicLoader`, and wires a few global listeners. Do not add per-feature imports here.

## DynamicLoader: lazy plugins and Vue islands

The `DynamicLoader` (`src/js/dynamic/`) is the heart of the frontend. It scans the DOM for marker attributes and lazily initialises behaviour when an element scrolls (almost) into view via `IntersectionObserver` (preloads ~200px before visibility).

It looks for two markers:

| Marker attribute      | Purpose                          | Module loaded                         |
| --------------------- | -------------------------------- | ------------------------------------- |
| `data-plugin="name"`  | vanilla TypeScript enhancement   | `@js/plugins/{name}/index.ts`         |
| `data-vue-component="name"` | Vue 3 island                | `@js/plugins/{name}/index.ts`         |

For each match it:

1. reads props from a `data-props` JSON attribute;
2. dynamically imports `@js/plugins/{name}/index.ts`;
3. calls that module's **default export** with `(element, props)`;
4. for plugins, removes the `data-plugin` attribute as a double-init guard.

Eager initialisation (skip the observer) is opt-in with `data-plugin-eager`.

This means: **to add frontend behaviour you create a plugin folder and reference it from Razor markup with a `data-plugin` / `data-vue-component` attribute — you never edit the loader.**

### Markup contract

```html
<!-- vanilla plugin -->
<div data-plugin="modal" data-props='{"closeOnEsc": true}'>
  ...
</div>

<!-- eager plugin (no lazy observer) -->
<div data-plugin="cookie-consent" data-plugin-eager>...</div>

<!-- Vue island -->
<div data-vue-component="helloworld" data-props='{"name": "Frontis"}'></div>
```

`data-props` must be valid JSON; it is parsed once and then removed from the DOM. Keep props small and serialisable — they are the boundary between Razor and the client.

## Plugin anatomy

Every plugin is a folder with an `index.ts` that default-exports an initializer matching:

```ts
type ElementInitializer = (element: HTMLElement, props: Record<string, any>) => void
```

A trivial plugin:

```ts
// src/js/plugins/helloworld/index.ts
import { HelloWorld } from './core/HelloWorld'

export default function (element: HTMLElement, props: { name?: string }) {
  HelloWorld.setup(element, props)
}
```

Non-trivial plugins follow a layered structure (see `modal`, `offcanvas`, `dropdown`):

```text
plugins/{name}/
  index.ts        # default export -> calls Core.setup(BLOCK)
  core/{Name}.ts  # orchestration / public API
  helpers/        # {Name}DOM.ts, {Name}Events.ts, {Name}StateManager.ts
  constants/      # selectors, dataset keys, default options
  README.md       # markup contract + API (recommended for reusable plugins)
```

Conventions:

- the `index.ts` default export receives the container element (often named `BLOCK`) and delegates to a `core` class `setup`;
- behaviour is driven by `data-*` attributes and dataset class-sets (for example `data-modal-open` / `data-modal-closed` hold the Tailwind class lists toggled on open/close), never hard-coded styling in JS;
- reuse the shared helpers in `src/js/helpers/` instead of reimplementing them:
  - `domUtils` — `selectElements`, `getDatasetClasses`, `switchDataSetClasses`, `removeCloak`;
  - `stateUtils` — `forceReflow`, `transitionState`;
  - `focusManager` — `FocusManager.activate/deactivate` (focus trap via `focus-trap`);
  - `animationUtils`, `scrollbarWidth`;
- accessibility is required for interactive plugins: manage `aria-*`, `inert`, focus and `Escape` like the existing modal/offcanvas plugins do.

### Adding a plugin

1. Create `src/js/plugins/{name}/index.ts` with a default-exported initializer.
2. Add `core`/`helpers`/`constants` files only when complexity warrants it — start small.
3. Reference it from Razor (a view, ViewComponent or Block List view) with `data-plugin="{name}"` and optional `data-props='{...}'`.
4. Add a `README.md` describing the markup contract when the plugin is reusable.
5. Run `npm run lint` and `npm run format`.

The dynamic import path is `@js/plugins/{name}/index.ts`, so the folder name and the `data-plugin` value must match exactly.

## Vue 3 islands

Vue is used for isolated interactive widgets, not for whole pages. A Vue island is just a plugin whose `index.ts` mounts a Vue app onto the element and is referenced with `data-vue-component`. Keep islands self-contained; do not introduce a global Vue app, Vuex/Pinia store, or Vue Router. State that must cross islands belongs on the server or in small DOM/event-based helpers.

`vue/multi-word-component-names` is disabled, so single-word component names are allowed.

## Styling with Tailwind CSS 4

Styling is Tailwind v4, configured entirely in `src/css/main.css`:

- `@import 'tailwindcss';` at the top; Tailwind v4 auto-scans template folders;
- the design system lives in `@theme` blocks — colors (`--color-primary`, `--color-secondary`, semantic `success`/`warning`/`danger`, …), breakpoints, and the `Inter` font;
- base element styles (`h1`–`h6`, `p`, `.rich-text`, `.lead`) live in `@layer base` and use `clamp()` for fluid type (via `postcss-clampwind`);
- reusable component classes are defined with `@utility` (for example `btn`, `btn-primary`, the `modal` panel) and project layout classes use the `fr-` prefix (`fr-container`, `fr-section`, `fr-block`, `fr-text`);
- dynamically-composed class names (used in `data-props` or generated server-side) must be added to the `@source inline(...)` safelist in `main.css`, otherwise Tailwind purges them.

Rules:

- prefer Tailwind utilities in markup over custom CSS;
- add shared visual patterns as `@utility` rules, not ad-hoc CSS;
- keep colors and tokens in `@theme` — do not hard-code hex values in components;
- `prettier-plugin-tailwindcss` enforces class ordering; run `npm run format`.

## Razor integration

The frontend is wired into Razor in the Web project:

- `Views/Master.cshtml` switches assets between the Vite dev server and built `dist` globs using `<environment>` tags:

```cshtml
<environment names="Development">
    <script type="module" defer src="/@@vite/client"></script>
    <script type="module" defer src="/src/js/main.ts"></script>
</environment>
<environment names="test,acc,prod">
    <link rel="stylesheet" asp-href-include="~/dist/main-*.css">
    <script type="module" defer asp-src-include="~/dist/bundle-main-*.js"></script>
</environment>
```

- page views stay tiny and compose Block List blocks via `<vc:block-list model="@Model.ContentBlockList" />`;
- frontend behaviour attaches to markup rendered by **ViewComponents** and **Block List views**, not partials (there is no `Partials` folder for content fragments — see the Umbraco document);
- Vite's `FullReload` plugin watches `Views/**`, `Components/**` and `BlockList/**` `.cshtml` files, so editing markup triggers a browser reload during development.

When you add a plugin or island, attach its `data-plugin` / `data-vue-component` marker in the relevant ViewComponent `.cshtml` or Block List view, and pass server data through `data-props`.

### Keep logic out of `.cshtml`

Razor views and Block List views should be **markup only**. Do almost no computation or branching in the `.cshtml` itself — push it into a **ViewComponent** (with an optional `ViewModel`) or into the block's `ViewModel`:

- no business logic, loops with side effects, string building, or data access in a view;
- no derived values that require more than a trivial helper call;
- expose computed data as typed properties on the ViewComponent/block view model and bind to them in the markup.

If a view starts accumulating more than a couple of these lines, or needs any real logic, move it into the (block) view model instead. See the Umbraco document for where view models and ViewComponents live.

## Tooling and commands

Run from `src/{App}.Web/`:

```powershell
npm install            # restore (Node 24)
npm run dev            # Vite dev server on port 5174 (proxied by the site)
npm run build          # production build -> wwwroot/dist/
npm run lint           # eslint (code) + vue-tsc (types)
npm run lint:code:fix  # autofix lint
npm run format         # prettier --write
npm run format:check   # prettier --check
```

For full local development run the backend (`dotnet watch`) and `npm run dev` together; `Master.cshtml` loads assets from the Vite dev server in the Development environment.

## Verification

Before claiming a frontend change is complete:

1. `npm run lint` passes (ESLint + `vue-tsc` type check) — the config is strict;
2. `npm run format:check` passes (or run `npm run format`);
3. `npm run build` succeeds and emits hashed bundles to `wwwroot/dist/`;
4. the behaviour works in the browser with `npm run dev` (lazy init means: confirm the element actually initialises when scrolled into view).

When frontend files changed, the Umbraco pull request checklist requires `npm run lint` to have passed.

## Anti-patterns

Avoid:

- editing the `DynamicLoader` to special-case a feature instead of adding a plugin;
- putting feature logic in `main.ts`;
- writing computation, branching or data access in `.cshtml` instead of a ViewComponent/(block) view model;
- importing plugins eagerly/statically instead of via the `data-plugin` marker;
- hard-coding styling in TypeScript instead of toggling dataset class-sets;
- inlining hex colors or magic class strings instead of using `@theme` tokens and `@utility`;
- forgetting the `@source inline(...)` safelist for dynamically-composed Tailwind classes;
- turning the site into an SPA, or adding a router / global store;
- skipping accessibility (focus, `aria-*`, `inert`, `Escape`) on interactive plugins;
- introducing a new bundler, CSS framework, or test framework without approval;
- editing files in `wwwroot/dist/` (build output) by hand;
- adding semicolons or trailing commas — Prettier config forbids them.

## Pull request checklist

Before opening or finishing a PR that touches the frontend:

```text
[ ] new behaviour added as a plugin/island under src/js/plugins/{name}/ with a default-exported initializer
[ ] referenced from Razor via data-plugin / data-vue-component (name matches the folder)
[ ] server data passed through data-props as valid JSON
[ ] .cshtml stays markup-only; computation/derived values live in a ViewComponent or (block) view model
[ ] shared logic reused from src/js/helpers/, not reimplemented
[ ] styling uses Tailwind utilities / @theme tokens / @utility, no hard-coded colors
[ ] dynamically-composed classes added to the @source inline safelist
[ ] interactive plugins handle focus, aria and Escape
[ ] npm run lint passed (ESLint + vue-tsc)
[ ] npm run format (or format:check) passed
[ ] npm run build succeeded
[ ] no edits to wwwroot/dist build output
```
