---
title: "Building a System to Generate Complex CSS"
description: "How HCTI turns typed template models into deterministic CSS shared by a Blazor editor and a server-side HTML renderer."
published: "2026-07-14"
author: "Jeffrey Needles"
canonical: "https://htmlcsstoimage.com/blog/generating-complex-css"
---


HCTI's template editor creates reusable designs that customers later render through our API. CSS is where the saved model, the live editor and that final image have to agree. A customer should not spend time getting a design right in the browser only to discover that the API render interprets a border, grid position or image fit differently.

That is harder than sharing a stylesheet. The editor builds its DOM with Blazor components and wraps blocks with selection UI, interaction handles and panel helpers. The server renderer writes a smaller HTML tree for Chromium. Preview values also need to temporarily override committed values without becoming part of the saved template.

We could have let every block component assemble its own styles and recreated the same rules in the server renderer. Early versions did more of that, and small differences accumulated quickly. CSS is full of rules that are individually obvious but behave differently once they meet parent layout, nested targets, variables and browser defaults.

Instead, the saved model is the shared input to a CSS production system. Attributes describe the common property-to-declaration cases, source generators emit the repetitive routing, and normal C# handles the values that need more context. Both renderers consume that output and attach it to equivalent named targets in their respective DOM trees.

::: card
#### Related Reading

The [architecture overview](/blog/building-a-visual-template-editor-in-blazor-wasm) maps the wider editor. Separate posts cover how model metadata reaches [Blazor property controls](/blog/consistent-blazor-ux-source-generators) and how [templatable values are resolved](/blog/beyond-text-replacement).
:::

## CSS Production

The editor stores user intent. The renderer still needs HTML and CSS.

CSS production therefore feels more like a compilation step than a string-building exercise. Blocks, panels, typed values and variables all need to become deterministic output. A `TemplateCssSize` needs to become the right CSS token. A panel in grid mode needs grid-specific CSS. A block inside that panel may need a very different positioning strategy than a block sitting directly on the canvas.

Because the model is structured, CSS can come from the saved data instead of scraped DOM nodes or a browser result we have to reverse engineer. The model says what it means, and the CSS producer turns that into declarations.

Sharing CSS does not require identical DOM trees. Both rendering paths expose the same named CSS targets for each block, giving generated declarations an equivalent place to land in the editor and final render. Preview CSS uses those targets too, then disappears to reveal the committed declaration underneath it.

### CSS Targets

One DOM element cannot cleanly own every part of a block. A child inside a flex or grid panel needs to participate in its parent's layout. The block still needs its own dimensions and transforms. Backgrounds, padding and borders need a stable visual box, while text or panel layout belongs with the actual content.

After a few painful iterations, we landed on four consistent CSS targets. From outside to inside:

| Target | Responsibility | Typical CSS |
| --- | --- | --- |
| `StructuredPanelChildWrapper` | Represents the block as a direct child of a structured panel. It lets the parent own placement without changing the block's internal structure. It is omitted for blocks directly on the canvas or inside a free-positioned panel. | Grid row/column placement, flex basis/grow/shrink, flow order, alignment and the dimensions or margins controlled by that parent layout. |
| `BlockOuter` | Owns the block's geometry relative to its parent. This is the stable outer box used for positioning and measurement. | Free positioning, dimensions and margins when they are not delegated to a structured wrapper, plus aspect ratio, opacity and transforms. |
| `BlockSurface` | Owns the block's normal painted box and the space between that box and its content. | Background, border, shadow, corner radius and padding. |
| `BlockInner` | Owns the block-specific content and any layout the block applies to its children. The actual element may be text, an image, an SVG shape or a panel container. | Typography, image or shape sizing, overflow, flex/grid container rules, gaps and child alignment. |

In the DOM, those targets nest like this:

```html
<div data-te-target="s_wrapper"> <!-- StructuredPanelChildWrapper -->
  <div data-te-target="b_outer"> <!-- BlockOuter -->
    <div data-te-target="b_surface"> <!-- BlockSurface -->
      <div data-te-target="b_inner"> <!-- BlockInner -->
        <!-- Text, image, shape, html or panel content -->
      </div>
    </div>
  </div>
</div>
```

These are CSS targets, not necessarily four visibly different boxes. For many block types, the surface and inner content occupy the same rectangle. Their names give generated CSS a stable destination for each declaration, even as block types and parent layouts change.

The layer structure also gives us room for the weird cases. An image block with `contain` or `scale-down` should not always draw its border around the whole available rectangle. It is usually better for the border or shadow to hug the actual image. If the same image also has a background fill, though, the surface matters again. Named targets let us express those rules without leaking them into one giant pile of CSS.

### From Metadata to CSS

Most CSS output starts as metadata on the model. A Roslyn source generator reads those attributes during compilation and emits the repetitive C# methods that turn each property into a declaration.

```csharp
public partial class TextUnderlineValue :
    IEnableStateProperty,
    ITrackingEquality<TextUnderlineValue>
{
    [CssDefinition(HasPreview = true)]
    public bool IsEnabled { get; set; }

    [CssDefinition(
        CssStyleKey = "text-decoration-color",
        CssTarget = LayoutTarget.BlockInner)]
    public TemplateProperty_Templatable<TemplateColor> Color { get; set; } =
        new(new("#000000"));

    [CssDefinition(
        CssStyleKey = "text-decoration-style",
        CssTarget = LayoutTarget.BlockInner)]
    public TextUnderlineStyle Style { get; set; } = TextUnderlineStyle.Solid;

    [CssDefinition(
        CssStyleKey = "text-decoration-thickness",
        CssTarget = LayoutTarget.BlockInner)]
    public Min0CssSize Thickness { get; set; } = new(1, CssSizeUnit.Px);

    [CssDefinition(
        CssStyleKey = "text-underline-offset",
        CssTarget = LayoutTarget.BlockInner)]
    public Min0CssSize Offset { get; set; } = new(0, CssSizeUnit.Px);

    [CssDefinition(
        CssStyleKey = "text-decoration-skip-ink",
        CssTarget = LayoutTarget.BlockInner)]
    public TextUnderlineSkipInk SkipInk { get; set; } = TextUnderlineSkipInk.Auto;
}

public enum TextUnderlineStyle : byte
{
    UNKNOWN = 0,
    [TemplateEnumOption(CssValue = "solid")]
    Solid = 1,
    [TemplateEnumOption(CssValue = "dotted")]
    Dotted = 2,
    [TemplateEnumOption(CssValue = "dashed")]
    Dashed = 3,
    [TemplateEnumOption(CssValue = "wavy")]
    Wavy = 4,
}
```

The bits that matter in that sample:

- `HasPreview`, which opts a property into generated preview state and preview CSS.
- `CssTarget`, which says which layer receives the declaration.
- `CssStyleKey`, which maps the value to a CSS declaration and participates in generated enum helpers.
- `CssMethodName`, which points the generator at a custom method when a direct declaration is not enough.

This is mostly about not having to remember where all the CSS paths are. If we add a new underline option, the model should say what CSS it produces and where it belongs. The renderer, editor stylesheet and preview path can all pick that up from the same place instead of relying on somebody to update every call site by hand.

From that, the generator can emit the repetitive parts:

```csharp
// Auto-generated, shortened for readability.

public partial class TextUnderlineValue
{
    public void AppendGeneratedBlockInnerStyleValues(
        ref CssBuilderV2 cssBuilder,
        TemplateBlockBase ownerBlock,
        TemplateVariableCtx? ctx = null)
    {
        var hasTemplateValues = ctx != null && ctx.template_values.Count > 0;

        AppendBlockInnerCssForColor(ref cssBuilder, ownerBlock, hasTemplateValues, ctx);
        AppendBlockInnerCssForStyle(ref cssBuilder, ownerBlock, hasTemplateValues, ctx);
        AppendBlockInnerCssForThickness(ref cssBuilder, ownerBlock, hasTemplateValues, ctx);
        AppendBlockInnerCssForOffset(ref cssBuilder, ownerBlock, hasTemplateValues, ctx);
        AppendBlockInnerCssForSkipInk(ref cssBuilder, ownerBlock, hasTemplateValues, ctx);
    }

    internal void AppendBlockInnerCssForColor(
        ref CssBuilderV2 cssBuilder,
        TemplateBlockBase ownerBlock,
        bool hasTemplateValues,
        TemplateVariableCtx? ctx)
    {
        if (!hasTemplateValues && !(ctx?.leave_empty_placeholders ?? false))
        {
            var resolution = GetResolutionWhenNoVariables(Color);
            if (resolution == TemplateCssValueResolution.UseFallback)
            {
                cssBuilder.AppendStyle(
                    TemplateCssStyleName.text_decoration_color,
                    Color.Value);
            }
        }
        else if (Color.TryResolveTemplateValue(ctx, out var resolved, nameof(Color)))
        {
            cssBuilder.AppendStyle(TemplateCssStyleName.text_decoration_color, resolved);
        }
    }

    internal void AppendBlockInnerCssForStyle(
        ref CssBuilderV2 cssBuilder,
        TemplateBlockBase ownerBlock,
        bool hasTemplateValues,
        TemplateVariableCtx? ctx)
    {
        cssBuilder.AppendStyle(
            TemplateCssStyleName.text_decoration_style,
            Style.CssValueBytes);
    }
}

extension(TextUnderlineStyle val)
{
    public ReadOnlySpan<byte> CssValueBytes => val switch
    {
        TextUnderlineStyle.Solid => "solid"u8,
        TextUnderlineStyle.Dotted => "dotted"u8,
        TextUnderlineStyle.Dashed => "dashed"u8,
        TextUnderlineStyle.Wavy => "wavy"u8,
        _ => ""u8
    };
}
```

We use the same generation path for server-side HTML rendering and for the editor's centralized stylesheets. Early versions drifted in small ways between what the editor showed and what the renderer emitted. Those are exactly the differences that make a visual editor feel untrustworthy, so both paths now get their declarations from the same code wherever possible.

### Edge Cases

CSS still has edge cases. The best we can do is give those cases a real place to live instead of letting them spread through the renderer one conditional at a time.

On `TemplateBlockBase`, each block can contribute defaults and runtime styles:

```csharp
internal virtual void AppendDefaultStyle(
    LayoutTarget target,
    ref CssBuilderV2 cssBuilder,
    TemplateVariableCtx? ctx = null) { }

internal virtual void AppendDefaultStyle__PREVIEW(
    LayoutTarget target,
    ref CssBuilderV2 cssBuilder,
    TemplateVariableCtx? ctx = null) { }

public virtual void AppendRuntimeStyles(
    LayoutTarget target,
    ref CssBuilderV2 cssBuilder,
    TemplateVariableCtx? ctx = null) { }
```

> [!NOTE]
> You may have noticed `CssBuilderV2` throughout our examples. It is a `ref struct` that writes CSS into UTF-8 buffers with minimal allocation. There is enough going on inside it for its own post, so we'll dig into that performance work separately.

Each block also gets generated methods like `AppendGeneratedBlockInnerStyleValues(...)`. Those methods know which properties target which layers, so they call the relevant property emitters and the block-level defaults for that target. Common cases stay generated. Special cases have a deliberate place to live.

### Overrides

Shared properties live on `TemplateBlockBase`, but their rendering is not always identical for every block type. Borders are a good example.

Most blocks put borders on `BlockSurface`. Shapes put them on `BlockInner`. Image blocks are dynamic: if the image is being contained and there is no background fill, the border and shadow may need to hug the image content instead of the full surface.

That is where `OverrideCssDefinition` and target deciders come in. The generated base class can expose a target decision instead of hard-coding one answer forever:

```csharp
// Auto-generated, shortened for readability.

public abstract partial class TemplateBlockBase
{
    public virtual LayoutTarget CssTarget_Border => this switch
    {
        ImageBlockData image => image.TargetForShadowBackgroundAndBorder(),
        ShapeBlockData shape => LayoutTarget.BlockInner,
        _ => LayoutTarget.BlockSurface
    };
}
```

The image-specific decision is just normal domain code, marked so the generator knows which properties it affects:

```csharp
[CssTargetDecider(nameof(Shadow), nameof(Border))]
public LayoutTarget TargetForShadowBackgroundAndBorder() =>
    HasMedia() &&
    !BackgroundFill.DisplayIsEnabled &&
    FitMode is ImageFitMode.Contain or ImageFitMode.ScaleDown
        ? LayoutTarget.BlockInner
        : LayoutTarget.BlockSurface;
```

That keeps the edge case close to the model that understands it. The source generator still wires it into the broader CSS pipeline.

### Custom Implementations

Some CSS cannot be expressed as "property X becomes declaration Y." Background gradients, patterns, shadows, margins and layout helpers all need more context.

There are two main escape hatches:

- A value type can implement `ITemplateCssStyleValue`, giving the generator an `AppendCssValue(...)` method to call.
- A property can specify `CssMethodName`, which tells the generator to call a custom method instead of appending the value directly. Those methods can accept the owner node too, so they can look at related state before writing CSS.

Plain declarations stay simple. Weird cases still go through the same generated pipeline. We do not fork into "generated CSS" and "special CSS" as two unrelated systems.

### Preview Stylesheets

In the Blazor editor, a block's markup and its generated styles live in separate components. Stylesheet components are keyed by block ID and watch the normal, preview and variable-context versions of that block. A color drag can therefore rebuild one small style element without asking the block component to render its markup again.

Preview CSS is emitted after the committed declarations, with `!important`, whenever `AnyPreview` is true. Clearing the preview removes that override and reveals the committed style underneath. The browser's CSS engine applies the change to the existing DOM, which is both less work for Blazor and a better fit for what browsers already do well.

## Conclusion

Any one of these declarations could have been written by hand. Keeping hundreds of them aligned across the Blazor editor, preview styles and server renderer is where that approach stopped being attractive.

The target layers give each declaration a stable destination even when a block moves between the canvas, flex panels and grids. Model attributes cover the ordinary mappings. Target deciders and custom methods handle the CSS that needs context. Source generation connects those pieces without maintaining parallel property lists in each renderer.

If a template model has not changed, its CSS output should not wander. That makes tests and support easier, and it gives every editable property a traceable path from the saved model to the final rendered image.

The editor DOM and the rendered HTML do not match one-to-one. They share the same target structure, and styles in both paths go through the generated pipeline. That keeps a convenient "let me just tweak that for the editor" fix from silently changing the editing experience without changing the final render.

That consistency is essential for a visual editor. If the canvas looks right but the generated image does not, the editor has failed at its most basic job.

## Template Editor Engineering Series

| Post | Description |
| --- | --- |
| [Building a Visual Template Editor in Blazor WASM](/blog/building-a-visual-template-editor-in-blazor-wasm) | A high-level tour of the decisions, architecture, and shared systems behind the editor. |
| [Building Consistent Blazor UX with Source Generators](/blog/consistent-blazor-ux-source-generators) | Turning typed C# models into consistent property controls, previews, and rendered blocks with source generators. |
| [Undo, Redo, and Live Preview - No Diffs Needed](/blog/editor-change-tracking) | Compact property addresses, generated tracking facades, transactions, undo/redo, and preview state. |
| [Why Our Blazor App Still Uses TypeScript](/blog/blazor-wasm-typescript-interop) | A practical split between Blazor-owned application state and TypeScript-owned browser behavior. |
| [Beyond Text Replacement: Building Typed Template Variables](/blog/beyond-text-replacement) | Keeping Handlebars for content while extending variables to colors, sizes, images, and other typed properties. |
| **Building a System to Generate Complex CSS** | Generating deterministic CSS for nested blocks, panels, previews, and both browser and server renderers. |
| [Simplifying Complexity: MemoryPack to the Rescue](/blog/memorypack-is-awesome) | MemoryPack storage, read-time schema repair, and typed MagicOnion communication across browser and server. |
| [Productionizing Our Blazor WebAssembly Editor](/blog/productionizing-blazor-wasm) | Runtime performance, trimming, loading experience, and browser-backed testing for a production Blazor WASM editor. |
