Components

Attributes

Attributes take HTML's names and render the same value on every target. Bound values, booleans, ARIA, data attributes, class, style and typed spreads.

Attributes take HTML's names, in lower case, and every target renders the same value for them. Each framework then gets its own spelling: React's className, Vue's :title, Angular's [attr.title].

Names

Write an attribute as HTML names it: class, for, tabindex, readonly, maxlength, accept-charset. React's spellings (className, htmlFor, tabIndex) are UF3004, with a fix that renames them. The compiler writes each framework's spelling for you:

AccountSettings.uf.tsx
<label for={bioId}>Bio</label>
<textarea id={bioId} name="bio" rows="3" maxlength={bioLimit} readonly={locked}></textarea>
AccountSettings.tsx
<label htmlFor={bioId}>Bio</label>
<textarea id={bioId} name="bio" rows={3} maxLength={bioLimit} readOnly={locked} />

The compiler also reports an attribute that the element does not have (UF3006), one that a framework reserves for itself, such as slot, is or ng* (UF3005), and the same attribute twice (UF3007).

Static values

A literal is written as a literal, so each attribute has one spelling (UF3004, with fixes):

  • title="Save", not title={"Save"};
  • disabled, not disabled={true}, disabled="" or disabled="disabled";
  • no attribute, rather than {false}, {null} or {undefined};
  • a number in its canonical form: tabindex="0", not tabindex="00".

A bare attribute is on for HTML's boolean attributes. For an ARIA state, draggable, spellcheck or a data-* attribute it means "true"; on any other attribute it is reported, because "true" is not what HTML means there.

Static values are checked too (UF3008): no javascript: URL, no data: document in a frame, ARIA values and roles that ARIA defines, and values of enumerated attributes that every target's types accept.

Bound values

name={expression} binds an attribute to a value. The value must be of a kind that every target renders alike for that attribute, and that every target's types accept, or it is UF3018:

AttributeTakes
A boolean attribute, such as disabled, open or requiredA boolean
An ARIA true/false state, such as aria-expanded, and draggable or spellcheckA boolean
An attribute of keywords, such as autocomplete, role or aria-liveA literal, or a union of literals, that every target accepts
A number-typed attribute, such as tabindex, colspan, maxlength or aria-levelA number
A string-typed attribute, such as id, title or aria-labelA string
data-* and every other attributeA string or a number

null and undefined leave the attribute out, on every target. A number renders as its decimal string, and "" as an empty attribute. A boolean turns a boolean attribute on or off, and renders as "true" or "false" elsewhere. A boolean in a data-* attribute renders differently from target to target, so write String(value) there. For an attribute of keywords, the diagnostic lists the ones every target accepts.

ProductTile.uf.tsx
<button type="button" disabled={!available}>
  Add {name} to basket
</button>
ProductTile.tsx
<button type="button" disabled={!available}>
  Add {name} to basket
</button>

Angular binds every attribute with [attr.name], and every boolean as [attr.name]="value ? '' : null". Svelte writes a bound value on an <li>, a <meter>, a <data>, a <button> or an <input> as a spread, {...{ value: score }}, because its first render writes nothing where the value equals the element's own default. Astro writes a boolean attribute its renderer does not know, such as multiple, as multiple={value ? "" : undefined}.

ARIA and data attributes

aria-* and role work on every element, HTML or SVG, and data-* holds your own data:

FilterPanel.uf.tsx
<section
  class="filter-panel"
  aria-label={heading}
  data-category={category}
  data-layout="stacked"
>
  <button type="button" aria-expanded={expanded} aria-describedby="filter-panel-hint">
    Price filters
  </button>
FilterPanel.tsx
<section
  className="filter-panel"
  aria-label={heading}
  data-category={category}
  data-layout="stacked"
>
  <button type="button" aria-expanded={expanded} aria-describedby="filter-panel-hint">
    Price filters
  </button>
  • An ARIA true/false state, such as aria-expanded or aria-pressed, takes a boolean and renders "true" or "false". Written bare, it means "true".
  • ARIA's text and reference attributes, such as aria-label and aria-describedby, take strings, and its numeric ones, such as aria-level or aria-valuenow, take numbers.
  • Static ARIA values and roles must be ones ARIA defines (UF3008): assistive technology ignores the others.
  • data-* takes a string or a number. Qwik drops a false there, and React drops it on some attributes, so write String(value) for a boolean.

What waits

These are UF1002, static or bound:

  • form state, which lands with v-model in M3: an <input>'s value and checked, an <option>'s selected, and a media element's muted;
  • names that some target's element types do not declare yet, such as popover, popovertarget, commandfor, ismap and writingsuggestions: that target's output would fail its type-check;
  • contenteditable on an element with children.

These keep their static form, and a bound one is UF1002:

  • a value that may be null or undefined on an <li>, a <meter>, a <progress>, a <data>, a <button>, an <option> or an <input> whose value is a label: Svelte and Solid render it as 0, null or "", where the other targets leave it out. Render the element only where the value is there, or give it a fallback, value={value ?? 0};
  • the boolean attributes that Vue or Svelte would render as ="false", such as hidden, itemscope and playsinline;
  • an <iframe>'s or an <embed>'s src and an <object>'s data, which Angular treats as resource URLs, and an <iframe>'s allow, allowfullscreen, referrerpolicy and sandbox, which Angular refuses to bind;
  • a <select>'s size and multiple, and an <option>'s or an <optgroup>'s disabled, which decide the selected option before some clients bind them.

Event handlers land in M2.

class

class takes a string, an array or an object, and an array can hold any of the three:

ActionButton.uf.tsx
export interface ActionButtonProps {
  label: string;
  size: "small" | "medium" | "large";
  primary: boolean;
  busy?: boolean;
  badge: number;
}

export default function ActionButton({
  label,
  size,
  primary,
  busy = false,
  badge,
}: ActionButtonProps) {
  return (
    <button
      type="button"
      class={[
        "button",
        [`button-${size}`, primary && "button-primary"],
        busy && "is-busy button-waiting",
        badge && "has-badge",
        primary ? "solid" : "outline",
      ]}
    >
      {label}
    </button>
  );
}
  • A string literal is a class name, or several.
  • An expression adds the class names its string holds. null, undefined and "" add none.
  • cond && "name" adds name when cond is truthy, and an object adds each key whose value is truthy.
  • cond ? "a" : "b" adds one or the other.

The element's class is the set of names those parts add, in any order. With no names, the element has no class attribute. Each target writes its own form, and React, whose className takes one string, gets a small helper, cx, at the end of its file:

ActionButton.tsx
export interface ActionButtonProps {
  label: string;
  size: "small" | "medium" | "large";
  primary: boolean;
  busy?: boolean;
  badge: number;
}

export default function ActionButton({
  label,
  size,
  primary,
  busy = false,
  badge,
}: ActionButtonProps) {
  return (
    <button
      type="button"
      className={cx(
        "button",
        `button-${size}`,
        { "button-primary": primary, "is-busy": busy, "button-waiting": busy, "has-badge": badge },
        primary ? "solid" : "outline",
      )}
    >
      {label}
    </button>
  );
}

/** 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(" ");
}

Solid writes class and classList when there is no expression part, and its own cx otherwise. Angular binds one [class] object, or one string when there is an expression part.

A class with only static names is written class="a b" (UF3004). A name listed twice is UF3007, with a fix that lists it once: Angular merges a static class with a bound one through the class list, which drops the repeat the other targets keep. cond && expression with an expression that is not a literal is UF3018, with a fix to cond ? expression : undefined. Arrays and objects that come from a prop land in M4.

style

style takes a CSS string, or an object of declarations:

UsageMeter.uf.tsx
<p style={{ color: colour, fontWeight: 700, lineHeight: 1.5, marginBottom: "4px" }}>
  {label}: {percent}%
</p>
  • A string is parsed as CSS declarations, and must hold at least one.
  • An object's keys are camelCase properties (marginTop) or quoted custom properties ("--gap"). A kebab-case key is UF3004, with a fix, and a vendor-prefixed one waits for M4.
  • A number is accepted only where CSS needs no unit, such as opacity, lineHeight or zIndex, and in a custom property (UF3018). A literal 0 is accepted on any length, as the static value 0. React and Qwik add px to the others, and Qwik also to SVG's stroke and opacity properties, such as strokeWidth, which React leaves bare. The fix writes the unit, "4px", or, on those SVG properties, the number as a string, "2".
  • A bound value that is null, undefined or "" leaves its declaration out.
UsageMeter.tsx
<p style={{ color: colour, fontWeight: 700, lineHeight: 1.5, marginBottom: "4px" }}>
  {label}: {percent}%
</p>

Vue and Angular write the static declarations as a style attribute beside the bound ones, and Svelte writes each declaration as a style: directive. Vue binds a static declaration that its own style parser would misread, such as one with a ; in a string. Svelte writes a static value that holds &, <, ", a tab, a line break or two spaces in a row as an expression, because its server would escape the text twice or fold the whitespace:

UsageMeter.svelte
  class="usage-meter-caption"
  style:color={colour}
  style:font-family={"\"UF Test Sans\", sans-serif"}
>{percent} of 100 used</p>

Solid's style objects take CSS names, and React's needs as CSSProperties when it holds a custom property. Static declarations stay static, a literal 0 included:

CommentQuote.uf.tsx
<blockquote style={{ margin: 0, padding: 0 }}>
CommentQuote.tsx
<blockquote style={{ margin: "0", padding: "0" }}>

A style follows these rules too (UF3022):

  • each property is one the browsers know, or a custom property;
  • a custom property's name is in lower case, because Angular lowercases it;
  • a static value reads the same to Angular's style parser, which knows no escapes or comments: a string holds no escaped quote of its own kind, the parentheses inside strings balance, and a comment holds no quote, parenthesis or semicolon;
  • each property is set once, and never a shorthand beside one of its longhands (margin with marginTop);
  • there is no !important.

React, Solid and Qwik write a style as an object, which cannot keep the order that decides between a shorthand and its longhand, and React drops !important. So the declarations' order never matters, and an element whose declarations are all left out has no style attribute. A style object that comes from a prop lands in M4.

Spreads

{...attributes} spreads an object whose type declares its keys: a prop, a list's item or a member of either, typed by an object type literal or a local interface or alias, or a conditional between one of these and null or undefined, as in {...(tracked ? tracking : undefined)}. A conditional between two sources is UF1002.

TextField.uf.tsx
interface FieldAttributes {
  name: string;
  placeholder?: string;
  autocomplete?: "email" | "username";
  maxlength?: number;
  required?: boolean;
  title?: string;
}

interface HintAttributes {
  id: string;
  title?: string;
}

export interface TextFieldProps {
  label: string;
  inputId: string;
  hintText: string;
  field: FieldAttributes;
  hint?: HintAttributes;
}

export default function TextField({ label, inputId, hintText, field, hint }: TextFieldProps) {
  return (
    <div class="text-field">
      <label for={inputId}>{label}</label>
      <input id={inputId} type="text" {...field} />
      <p class="text-field-hint" {...hint}>
        {hintText}
      </p>
    </div>
  );
}

A spread renders exactly the keys its type declares, and checks each one as if it were written on the element: its name, and the kinds of its value. So every target writes the spread out, one attribute per key. When the source may be null or undefined where it renders, by its type or its default, each key reads through ?.:

TextField.tsx
<input
  id={inputId}
  type="text"
  name={field.name}
  placeholder={field.placeholder}
  autoComplete={field.autocomplete}
  maxLength={field.maxlength}
  required={field.required}
  title={field.title}
/>
<p className="text-field-hint" id={hint?.id} title={hint?.title}>
  {hintText}
</p>

A condition around the spread that tests its source narrows it, as in any expression (Narrowing), and each key then reads through .. Solid's output reads them from the value its keyed <Show> hands the branch:

ShipmentCard.uf.tsx
{signature && <p {...signature}>Signed on delivery</p>}
{!parcel.seal ? <p>Not sealed</p> : <p {...parcel.seal}>Sealed</p>}
ShipmentCard.tsx
{signature ? (
  <p id={signature.id} title={signature.title}>
    Signed on delivery
  </p>
) : null}
{!parcel.seal ? (
  <p>Not sealed</p>
) : (
  <p id={parcel.seal.id} title={parcel.seal.title}>
    Sealed
  </p>
)}

A spread in a branch that renders only when its source is absent renders nothing: UF3004, with a fix that removes it. Where the targets' checkers would read the source apart, a prop or a list's item is read through ?., and a member is UF1002.

  • A key that is also written on the element, or that two spreads set, is UF3007. A class key is the exception: it merges with the element's own class.
  • A spread of an object literal is UF3004, with a fix that writes its attributes out.
  • A style key lands in M4, and key, ref and children keys in M3.
  • A spread of an object whose keys the compiler cannot see is fallthrough, which lands in M3.

An object passed in with more keys than its type declares renders only the declared ones.

The decisions behind this page are ADR-0017, ADR-0030, ADR-0037, ADR-0038 and ADR-0039.

Copyright © 2026