Components

Fallthrough

A parent's class and style on a component fall through to the component's root element, merged with the root's own. defineOptions({ inheritAttrs: false }) turns it off. Every framework gets the same two attributes.

A parent often needs to place or tweak a child it doesn't own: give a tag some margin, mark the picked one, widen a button. So a parent's class and style on a component fall through to the component's root element, merged with the root's own. They're the only attributes that do: every other attribute is a prop the child declares, so the child's API stays the whole story of what it accepts.

Class and style

A component with one root element takes class and style without declaring them. The parent passes them as it would to an element, static or bound:

Tag.uf.tsx
export default function Tag({ label }: { label: string }) {
  return (
    <span class="tag" style="padding: 2px 6px; color: rgb(30, 30, 30)">
      {label}
    </span>
  );
}
Tags.uf.tsx
import { ref } from "unframework";

import Tag from "./Tag.uf.tsx";

export default function Tags() {
  const active = ref(false);
  return (
    <div class="tags">
      <Tag label="plain" />
      <Tag label="wide" class="wide" style="margin-left: 8px" />
      <Tag
        label="toggled"
        class={["picked", { active: active.value }]}
        style={{ color: active.value ? "rgb(200, 0, 0)" : undefined }}
      />
      <button type="button" onClick={() => (active.value = !active.value)}>
        Toggle
      </button>
    </div>
  );
}

The root's class is the union of both sets of names: tag wide, or tag picked active. For a style property both set, the parent's declaration wins, and a property the parent sets to null or undefined is removed from the root, as an object merge does. So while active is false, the toggled tag has no color at all.

Tag.tsx
import type { CSSProperties } from "react";

export interface TagAttributes {
  className?: string;
  style?: CSSProperties;
}

export default function Tag({ label, className, style }: { label: string } & TagAttributes) {
  return (
    <span
      className={cx("tag", className)}
      style={{ padding: "2px 6px", color: "rgb(30, 30, 30)", ...style }}
    >
      {label}
    </span>
  );
}

/** Joins class names, and the keys of an object's truthy entries, into one `className`. */
function cx(...parts: unknown[]): string {
  const names: string[] = [];
  for (const part of parts) {
    if (typeof part === "string") {
      if (part) names.push(part);
    } else if (part && typeof part === "object") {
      for (const [key, on] of Object.entries(part)) {
        if (on) names.push(key);
      }
    }
  }
  return names.join(" ");
}
Tags.tsx
import Tag from "./Tag";
import { type CSSProperties, useRef, useState } from "react";

export interface TagsAttributes {
  className?: string;
  style?: CSSProperties;
}

export default function Tags({ className, style }: TagsAttributes) {
  const [active, setActive] = useState(false);
  const activeRef = useRef(active);

  return (
    <div className={cx("tags", className)} style={style}>
      <Tag label="plain" />
      <Tag label="wide" className="wide" style={{ marginLeft: "8px" }} />
      <Tag
        label="toggled"
        className={cx("picked", { active })}
        style={{ color: active ? "rgb(200, 0, 0)" : undefined }}
      />
      <button
        type="button"
        onClick={() => {
          activeRef.current = !activeRef.current;
          setActive(activeRef.current);
        }}
      >
        Toggle
      </button>
    </div>
  );
}

/** Joins class names, and the keys of an object's truthy entries, into one `className`. */
function cx(...parts: unknown[]): string {
  const names: string[] = [];
  for (const part of parts) {
    if (typeof part === "string") {
      if (part) names.push(part);
    } else if (part && typeof part === "object") {
      for (const [key, on] of Object.entries(part)) {
        if (on) names.push(key);
      }
    }
  }
  return names.join(" ");
}
  • React, Solid, Qwik and Astro declare className or class, and style, beside the props, and merge them into the root: the class through cx on React, a template string on Solid, an array on Qwik and class:list on Astro, the style by spreading the parent's object over the root's own.
  • Vue passes both on by itself.
  • Svelte declares class and style props too. A Svelte style is a string, which can't unset a property, so where the root has a style of its own an inline helper, mergeStyle, merges the parent's declarations over it and drops the root's declaration of a property the parent left empty (fallthrough, emulated). A root with no style of its own takes {style} as it is.
  • Angular declares class and style inputs, merges them into the root element, and clears them from its host element, which keeps only display: contents (fallthrough, emulated). A parent binds them, [class]="'wide'", so they reach the inputs.

So every output but Vue's declares the two on each component with one root element, and you'll see them on every page of these docs, even where no parent passes them.

Through a component root

A component whose root is another component passes the parent's class and style on to that component's root:

Primary.uf.tsx
import Base from "./Base.uf.tsx";

// Its root is a component: a class passed to it passes on to that component's root.
export default function Primary({ text }: { text: string }) {
  return <Base text={text} class="primary" />;
}

<Primary text="Save" class="wide" /> renders <button class="base primary wide">: Base merges primary and wide into its own base.

Turning it off

defineOptions({ inheritAttrs: false }) makes a component take neither, so its root keeps a layout of its own:

Field.uf.tsx
import { defineOptions } from "unframework";

// Renders no class or style a consumer passes: its own layout is fixed.
export default function Field({ label }: { label: string }) {
  defineOptions({ inheritAttrs: false });
  return (
    <label class="field">
      {label} <input name={label.toLowerCase()} />
    </label>
  );
}

Vue writes the same defineOptions. The other targets leave out the class and style props, and Angular's host still clears both. The argument is that object literal and nothing else (UF2031).

A parent that passes class or style to such a component has them rejected rather than dropped (UF3045), and so does one that passes them to a component whose root is a fragment, a conditional or a list, which has no one root to take them. Style an element around the component instead, or give the child a prop for its class.

Only class and style

No other attribute falls through. An id, an aria-* or a data-* that the child doesn't declare is UF3035, with a fix to the closest prop, and a listener the child doesn't declare is UF3036. A design system that lets its users set them declares them as props. A rest element in the props, { label, ...rest }, is UF2001: fallthrough has one channel, and it is class and style.

Angular has no attribute spread, and a typed parent already rejects an attribute its child doesn't declare, which is why Unframework keeps fallthrough to these two on every target rather than Vue's every attribute.

The decisions behind this page are ADR-0054 and ADR-0056.

Copyright © 2026