In my previous article, CSS-Only Custom Elements, I used an undefined custom-element tag as a styling hook. The browser does not need a customElements.define() call to parse, select, or style a tag like <my-badge>.
That same idea works well for layout primitives. Using CSS, let's create <my-flex> and <my-grid> elements with defined attributes to control the layout. There is no JavaScript, no shadow DOM, and no layout runtime. The markup is the API.
Getting Started
Both flexbox and grid both have clear patterns to their APIs, which make them good candidates for a reusable component. The first version will support these attributes:
| Element | Attribute | Values |
|---|---|---|
my-flex |
direction |
row, column, row-reverse, column-reverse
|
my-flex |
gap |
none, xs, sm, md, lg, xl
|
my-flex |
align |
start, center, end, stretch, baseline
|
my-flex |
justify |
start, center, end, between, around, evenly
|
my-flex |
wrap |
Boolean attribute |
my-grid |
columns |
1, 2, 3, 4, 6, 12
|
my-grid |
gap |
none, xs, sm, md, lg, xl
|
my-column |
column, row, span, area
|
Placement attributes for a grid child |
my-flex, my-grid
|
inline |
Boolean attribute |
Building my-flex
Flexbox already has a compact vocabulary for one-dimensional layout. Each attribute maps to one CSS property or a small group of values.
my-flex {
/* default styles */
display: flex;
align-items: var(--my-layout-align);
justify-content: var(--my-layout-justify);
flex-direction: row;
flex-wrap: nowrap;
box-sizing: border-box;
gap: var(--my-layout-gap);
/* configurable variables in case overrides are needed */
--my-layout-gap: 0;
--my-layout-align: stretch;
--my-layout-justify: start;
/* controls spacing between elements */
&[gap="none"] { --my-layout-gap: 0; }
&[gap="xs"] { --my-layout-gap: 0.25rem; }
&[gap="sm"] { --my-layout-gap: 0.5rem; }
&[gap="md"] { --my-layout-gap: 1rem; }
&[gap="lg"] { --my-layout-gap: 1.5rem; }
&[gap="xl"] { --my-layout-gap: 2rem; }
/* allows overflow items to wrap to the next line */
&[wrap] { flex-wrap: wrap; }
/* allows the element to be inline instead of filling the full width of the container */
&[inline] { display: inline-flex; }
/* controls the direction the content flows */
&[direction="column"] { flex-direction: column; }
&[direction="row-reverse"] { flex-direction: row-reverse; }
&[direction="column-reverse"] { flex-direction: column-reverse; }
/* controls the alignment of the elements in the container */
&[align="start"] { --my-layout-align: flex-start; }
&[align="center"] { --my-layout-align: center; }
&[align="end"] { --my-layout-align: flex-end; }
&[align="baseline"] { --my-layout-align: baseline; }
/* controls the positioning of the elements in the container */
&[justify="center"] { --my-layout-justify: center; }
&[justify="end"] { --my-layout-justify: flex-end; }
&[justify="between"] { --my-layout-justify: space-between; }
&[justify="around"] { --my-layout-justify: space-around; }
&[justify="evenly"] { --my-layout-justify: space-evenly; }
}
The resulting markup reads like a small layout language:
<my-flex direction="column" gap="md" align="center">
<h2>Account settings</h2>
<p>Update your profile and notification preferences.</p>
<my-flex gap="sm">
<button type="button">Cancel</button>
<button type="submit">Save changes</button>
</my-flex>
</my-flex>
The nested my-flex is just another flex container. Because the custom properties are inherited, the shared gap value is available to descendants, but each nested element can replace it with its own gap attribute.
Building my-grid
Grid needs one additional concept: how many tracks the container should have. The columns attribute handles common track counts, and custom properties remain available for more specialized grids.
my-grid {
display: grid;
align-items: var(--my-layout-align);
justify-items: var(--my-layout-justify);
grid-template-columns: repeat(1, minmax(0, 1fr));
--my-layout-gap: 0;
--my-layout-align: stretch;
--my-layout-justify: start;
box-sizing: border-box;
gap: var(--my-layout-gap);
&[gap="none"] { --my-layout-gap: 0; }
&[gap="xs"] { --my-layout-gap: 0.25rem; }
&[gap="sm"] { --my-layout-gap: 0.5rem; }
&[gap="md"] { --my-layout-gap: 1rem; }
&[gap="lg"] { --my-layout-gap: 1.5rem; }
&[gap="xl"] { --my-layout-gap: 2rem; }
&[inline] { display: inline-grid; }
&[columns="2"] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
&[columns="3"] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
&[columns="4"] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
&[columns="6"] { grid-template-columns: repeat(6, minmax(0, 1fr)); }
&[columns="12"] { grid-template-columns: repeat(12, minmax(0, 1fr)); }
&[align="start"] { --my-layout-align: start; }
&[align="center"] { --my-layout-align: center; }
&[align="end"] { --my-layout-align: end; }
&[justify="start"] { --my-layout-justify: start; }
&[justify="center"] { --my-layout-justify: center; }
&[justify="end"] { --my-layout-justify: end; }
}
Grid children can expose placement attributes too. Since CSS cannot calculate an arbitrary attribute value portably, the stylesheet maps the supported values explicitly. The selectors can target any element, so semantic elements remain available:
my-grid {
> [column="1"] { grid-column: 1; }
> [column="2"] { grid-column: 2; }
> [column="3"] { grid-column: 3; }
> [column="4"] { grid-column: 4; }
> [row="1"] { grid-row: 1; }
> [row="2"] { grid-row: 2; }
> [row="3"] { grid-row: 3; }
> [span="2"] { grid-column: span 2; }
> [span="3"] { grid-column: span 3; }
> [span="4"] { grid-column: span 4; }
}
Here is a dashboard using those attributes:
<my-grid columns="4" gap="md">
<section column="1" span="4">
<h2>Overview</h2>
</section>
<section column="1" span="2">
<h2>Activity</h2>
</section>
<section column="3" span="2">
<h2>Usage</h2>
</section>
</my-grid>
Building <my-column>
We can also provide a documented <my-column> element for consumers who want the layout API to be visible in markup. This is more than a cosmetic naming preference. A custom element gives documentation and tooling a concrete API surface to discover. A Custom Elements Manifest can describe my-column and its column, row, span, and area attributes, allowing documentation generators, editor integrations, and framework type declarations to expose autocomplete and type checking. The same stylesheet still supports arbitrary elements when semantic markup is more important:
my-grid {
my-column {
display: block;
min-width: 0;
&[column~="1"] { grid-column: 1; }
&[column~="2"] { grid-column: 2; }
&[column~="3"] { grid-column: 3; }
&[column~="4"] { grid-column: 4; }
&[row~="1"] { grid-row: 1; }
&[row~="2"] { grid-row: 2; }
&[row~="3"] { grid-row: 3; }
&[span~="2"] { grid-column: span 2; }
&[span~="3"] { grid-column: span 3; }
&[span~="4"] { grid-column: span 4; }
}
}
These are useful when the semantics of the immediate child elements is not important. my-column does not add semantics by itself. It is a documented layout primitive:
<my-grid columns="4" gap="md">
<my-column span="4">
<h2>Overview</h2>
</my-column>
<my-column span="2">
<h2>Activity</h2>
</my-column>
<my-column span="2">
<h2>Usage</h2>
</my-column>
</my-grid>
.
For a layout with meaningful named regions, grid-template-areas is often clearer than numeric placement:
my-grid {
&[layout="application"] {
grid-template-columns: 16rem minmax(0, 1fr);
grid-template-areas:
"sidebar content";
> [area="sidebar"] { grid-area: sidebar; }
> [area="content"] { grid-area: content; }
}
}
<my-grid layout="application" gap="lg">
<aside area="sidebar">Navigation</aside>
<main area="content">Main content</main>
</my-grid>
Responsive layouts
Having layout components like this would not be very useful if they were not responsive. We can start with media queries, which keep the first responsive implementation straightforward. Values are space-delimited, and the unprefixed token is the default:
<my-flex direction="row sm:column" gap="sm md:lg">
...
</my-flex>
<my-column span="3 md:6 sm:12">
...
</my-column>
The first example is a row by default and becomes a column at the sm breakpoint. The second column spans three tracks by default, six at md, and twelve at sm. When breakpoint ranges overlap, the stylesheet order determines the winner, so the breakpoints should be declared from smallest to largest.
The value before the colon is a breakpoint token, not part of the CSS value. In order to make this happen, let's update our attribute selectors to use ~=. This finds one whitespace-delimited token, so direction="row sm:column" can have both a default value and a responsive value without JavaScript.
/* Breakpoints are ordered from smallest to largest. */
@media (min-width: 30rem) {
my-flex {
&[direction~="sm:column"] { flex-direction: column; }
&[gap~="sm:md"] { --my-layout-gap: 1rem; }
}
my-grid {
&[gap~="sm:md"] { --my-layout-gap: 1rem; }
&[columns~="sm:2"] {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
> [span~="sm:12"] { grid-column: span 12; }
}
}
@media (min-width: 48rem) {
my-flex {
&[direction~="md:row"] { flex-direction: row; }
&[gap~="md:lg"] { --my-layout-gap: 1.5rem; }
}
my-grid {
&[gap~="md:lg"] { --my-layout-gap: 1.5rem; }
&[columns~="md:4"] {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
> [span~="md:6"] { grid-column: span 6; }
}
}
Responsive layouts with container queries
Media queries respond to the viewport. A reusable layout component may instead need to respond to the width available to it. Container queries provide that behavior, but the container must be an ancestor of the element being changed. A container query cannot query an element's own size to change that same element.
https://...
An explicit class or attribute can mark a native element as a query container:
<section layout-container>
<my-flex direction="row sm:column">
...
</my-flex>
</section>
[layout-container] {
container-type: inline-size;
container-name: layout;
}
If the container is part of the documented layout API, a CSS-only custom element like <my-layout> can make the boundary more discoverable:
<my-layout>
<my-grid columns="3 md:4" gap="sm md:lg">
<my-column span="3 md:6 sm:12">Primary content</my-column>
<my-column span="3 md:6 sm:12">Secondary content</my-column>
</my-grid>
</my-layout>
/** A CSS-only container-query boundary for responsive layouts. */
my-layout {
display: block;
container-type: inline-size;
container-name: layout;
}
The responsive selectors can now be placed inside container queries:
@container layout (min-width: 30rem) {
my-flex {
&[direction~="sm:column"] { flex-direction: column; }
}
my-grid {
&[columns~="sm:2"] {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
> [span~="sm:12"] { grid-column: span 12; }
}
}
@container layout (min-width: 48rem) {
my-grid {
&[columns~="md:4"] {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
> [span~="md:6"] { grid-column: span 6; }
}
}
The same placement rules work when the child is a native element:
<my-grid columns="3 md:4">
<article span="3 md:6 sm:12">Primary content</article>
<aside span="3 md:6 sm:12">Secondary content</aside>
</my-grid>
my-layout can be documented with the same JSDoc CSS detector as the other elements.
An automatic alternative such as :has(> my-flex, > my-grid) can mark ancestors as containers, but it may match more ancestors than intended. An explicit attribute or custom element makes the boundary visible and predictable.
Documenting the API
CSS-only elements can still be included in a Custom Elements Manifest. CEM Generator scans CSS for undefined custom-element selectors when they are immediately preceded by a JSDoc-style comment. The comment becomes the element description. Attributes and CSS custom properties can also be documented with @attr and @cssprop tags.
/**
* A one-dimensional CSS-only flex layout.
* @attr direction - Flex direction.
* @attr gap - Spacing token.
* @attr {boolean} wrap - Allow children to wrap onto multiple lines.
* @cssprop --my-layout-gap - Exact gap value override.
*/
my-flex {
/** Default spacing token resolved by the gap attribute. */
--my-layout-gap: 0;
&[direction="row"] { flex-direction: row; }
&[direction="column"] { flex-direction: column; }
&[wrap] { flex-wrap: wrap; }
}
/**
* A documented grid child.
* @attr span - Number of columns to span.
* @attr {string} area - Named grid area.
*/
my-column {
...
}
The comments must be directly before the selector they document. Property comments are also significant: CEM Generator only treats custom properties with their own preceding JSDoc comment as public CSS properties. The generated manifest can then describe my-flex, my-grid, and my-column even though none of them has a JavaScript class.
The placement attributes remain usable on arbitrary semantic elements. Those elements will not appear as custom-element declarations in the manifest, but the documented my-column gives tools a discoverable, typeable API for consumers who want one.
Accessibility and semantics
These elements are layout primitives, not semantic replacements for native HTML. An undefined <my-grid> has no landmark or grouping semantics. Keep meaningful elements inside it: headings, lists, navigation, main, section, and so on.
The source order should also be meaningful. Grid placement and flex order can change visual order without changing DOM or reading order. Avoid using placement attributes to make a screen reader encounter content in a confusing sequence, and do not use them to reorder interactive controls unless the source order remains equivalent.
When you should use these
CSS-only layout elements are useful when the goal is a small, declarative vocabulary around existing CSS primitives:
- A design system can standardize spacing and alignment tokens.
- Templates can communicate layout intent without class-name conventions.
- Nested layouts stay composable because every element is an ordinary DOM container.
- There is no JavaScript cost or custom-element upgrade lifecycle.
They are not a replacement for JavaScript when layout depends on application state, measurement, data, or interaction. They also do not provide shadow-DOM style isolation. Keep selectors scoped, document the supported attributes, and treat the attribute vocabulary as a public API.
Conclusion
my-flex and my-grid are not defined custom elements in the JavaScript sense. They are valid custom-element-shaped HTML that CSS can turn into useful layout primitives.
The important design decision is to make the attribute contract finite and predictable. Attributes select documented CSS rules; custom properties provide an escape hatch for exact values. That gives us readable markup without pretending that CSS can parse arbitrary layout syntax safely everywhere.
For simple layout composition, a tag, a few attributes, and a stylesheet are enough.
Top comments (0)