---
title: Elements & attributes
---

# Elements & attributes

How JSX elements and attributes map to HTML.

## Lowercase elements are HTML

A lowercase tag is an HTML element; an uppercase one is a **component**.
[Void elements](https://developer.mozilla.org/en-US/docs/Glossary/Void_element)
(`img`, `br`, `input`, …) render without a closing tag.

```tsx
import { renderToString } from "@vincle/core";

const html = await renderToString(
  <>
    <input type="text" />
    <br />
  </>,
);
```

```html
<input type="text" /><br />
```

### A void element takes no children

HTML has no form for content inside a void element: a parser would move it.
So any `children` other than `undefined` throws, even a value that renders
nothing (`{list.length > 0 && <b>x</b>}` throws on an empty list too), and the
mistake shows in development, not only when real data arrives.

```
[vincle/core] <img> is a void element and cannot have children: move the content next to it, not inside.
```

```tsx
<img src={src}>Caption</img>       // ❌ throws
<img src={src} alt="Caption" />    // ✅
<figure><img src={src} /><figcaption>Caption</figcaption></figure>  // ✅
```

The error is thrown when the element is created, so JSX built outside a
component throws before `renderToString` is called. To catch it while writing,
enable
[`void-dom-elements-no-children`](https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/void-dom-elements-no-children.md)
(`eslint-plugin-react`, ported by oxlint). It flags explicit children only:
children passed through a spread still reach the runtime check.

## Attributes

Most names pass through unchanged. The React spellings map to their HTML
names:

| JSX name        | HTML attribute   |
| --------------- | ---------------- |
| `className`     | `class`          |
| `htmlFor`       | `for`            |
| `tabIndex`      | `tabindex`       |
| `readOnly`      | `readonly`       |
| `autoComplete`  | `autocomplete`   |
| `colSpan`       | `colspan`        |
| `httpEquiv`     | `http-equiv`     |
| `acceptCharset` | `accept-charset` |

When both spellings reach one element, the **HTML name wins** and the React
one is dropped: no merge, no warning. Prefer the HTML names: they match the
output.

```tsx
import { renderToString } from "@vincle/core";

const html = await renderToString(<div className="foo" class="bar" />);
```

```html
<div class="bar"></div>
```

## Boolean and falsy values

`null` and `undefined` omit the attribute. On an HTML boolean attribute
(`disabled`, `hidden`, `checked`…), `false` omits it and `true` renders the bare
name; on any other attribute, `true` and `false` render as text.

```tsx
<button disabled={isBusy}>Save</button>
// isBusy true  → <button disabled>Save</button>
// isBusy false → <button>Save</button>
```

## The `style` attribute

`style` accepts either a string or a `CSSProperties` object. Object values use
camelCase keys (like React) and are converted to kebab-case CSS properties.

```tsx
import { renderToString } from "@vincle/core";

// String style
const html1 = await renderToString(<p style="color: red; font-size: 1.5rem;">Hello</p>);

// Object style (camelCase → kebab-case conversion)
const html2 = await renderToString(<p style={{ color: "red", fontFamily: "sans-serif" }}>Hello</p>);
```

A string is your declaration list, escaped for the attribute and otherwise
emitted as written. An object is data: names carrying CSS syntax are dropped
and values are CSS-escaped, so a value cannot add a declaration. See the
[security model](/guide/security).

## Event handlers

A function has no HTML form, and there is no client runtime to attach it to:
any function-valued attribute **throws** at render time, `on*` or not. A string
handler (`onClick="doThing()"`) is escaped, but the JavaScript inside is yours:
never interpolate user data there. The
[`no-unsafe-event-handlers`](https://github.com/cjean-fr/vincle/tree/main/packages/eslint-plugin)
rule flags the practice at the source.

## Custom attributes

Any name renders as written (`data-*`, `aria-*`, `hx-get`), as long as it is
valid: no whitespace, quote, `<`, `>`, `/`, `=` or control character.

## Where to next

- [First render](/guide/getting-started/first-render): your first component
- [Security model](/guide/security): the URL allowlist
