Fallthrough
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:
export default function Tag({ label }: { label: string }) {
return (
<span class="tag" style="padding: 2px 6px; color: rgb(30, 30, 30)">
{label}
</span>
);
}
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.
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(" ");
}
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
classNameorclass, andstyle, beside the props, and merge them into the root: the class throughcxon React, a template string on Solid, an array on Qwik andclass:liston Astro, the style by spreading the parent's object over the root's own. - Vue passes both on by itself.
- Svelte declares
classandstyleprops too. A Sveltestyleis 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
classandstyleinputs, merges them into the root element, and clears them from its host element, which keeps onlydisplay: 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:
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:
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.
Models
defineModel declares a value a parent can bind two ways with v-model, and v-model on a form control binds its state to a ref. Each framework gets its own two-way binding, with Vue's modifiers emulated where it has none.
Expose and component refs
defineExpose lists the functions a parent may call, and a template ref on a component holds them. Each framework exposes them its own way, from React's imperative handle to Angular's public methods.