Attributes
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:
<label for={bioId}>Bio</label>
<textarea id={bioId} name="bio" rows="3" maxlength={bioLimit} readonly={locked}></textarea>
<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", nottitle={"Save"};disabled, notdisabled={true},disabled=""ordisabled="disabled";- no attribute, rather than
{false},{null}or{undefined}; - a number in its canonical form:
tabindex="0", nottabindex="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:
| Attribute | Takes |
|---|---|
A boolean attribute, such as disabled, open or required | A boolean |
An ARIA true/false state, such as aria-expanded, and draggable or spellcheck | A boolean |
An attribute of keywords, such as autocomplete, role or aria-live | A literal, or a union of literals, that every target accepts |
A number-typed attribute, such as tabindex, colspan, maxlength or aria-level | A number |
A string-typed attribute, such as id, title or aria-label | A string |
data-* and every other attribute | A 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.
<button type="button" disabled={!available}>
Add {name} to basket
</button>
<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:
<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>
<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-expandedoraria-pressed, takes a boolean and renders"true"or"false". Written bare, it means"true". - ARIA's text and reference attributes, such as
aria-labelandaria-describedby, take strings, and its numeric ones, such asaria-leveloraria-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 afalsethere, and React drops it on some attributes, so writeString(value)for a boolean.
What waits
These are UF1002, static or bound:
- form state, which lands with
v-modelin M3: an<input>'svalueandchecked, an<option>'sselected, and a media element'smuted; - names that some target's element types do not declare yet, such as
popover,popovertarget,commandfor,ismapandwritingsuggestions: that target's output would fail its type-check; contenteditableon an element with children.
These keep their static form, and a bound one is UF1002:
- a
valuethat may benullorundefinedon 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 as0,nullor"", 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 ashidden,itemscopeandplaysinline; - an
<iframe>'s or an<embed>'ssrcand an<object>'sdata, which Angular treats as resource URLs, and an<iframe>'sallow,allowfullscreen,referrerpolicyandsandbox, which Angular refuses to bind; - a
<select>'ssizeandmultiple, and an<option>'s or an<optgroup>'sdisabled, 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:
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,undefinedand""add none. cond && "name"addsnamewhencondis 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:
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:
<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,lineHeightorzIndex, and in a custom property (UF3018). A literal0is accepted on any length, as the static value0. React and Qwik addpxto the others, and Qwik also to SVG's stroke and opacity properties, such asstrokeWidth, 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,undefinedor""leaves its declaration out.
<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:
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:
<blockquote style={{ margin: 0, padding: 0 }}>
<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 (
marginwithmarginTop); - 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.
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 ?.:
<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:
{signature && <p {...signature}>Signed on delivery</p>}
{!parcel.seal ? <p>Not sealed</p> : <p {...parcel.seal}>Sealed</p>}
{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
classkey 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
stylekey lands in M4, andkey,refandchildrenkeys 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.