Primitives¶
The 14 components cf-ui shipped through 0.2.0 are all structural — card, modal, navbar, table. Primitives are the layer underneath: the small, high-frequency elements that structural components are built out of and that consuming apps otherwise hand-write everywhere.
They differ from structural components in one way that shapes everything
below: a primitive is used hundreds of times per app, so its prop contract has
to be settled once, for the whole set, before any of it is implemented. A
button that spells its colours variant while a badge spells them type is a
worse API than either choice on its own.
The shared vocabularies¶
Six axes, each a closed set, declared in
cf_ui/primitives.py.
An unknown value raises PrimitiveConfigError naming the values that would
have worked — it does not fall through to an unstyled element.
| Axis | Values | Becomes |
|---|---|---|
variant |
primary secondary success warning danger info neutral |
a class |
size |
small normal large |
a class |
state |
normal loading disabled |
a class |
emphasis |
normal subtle |
a class |
level |
1–6 |
the tag |
type |
button submit reset |
an attribute |
Seven variants, not "whatever the framework ships". All five frameworks can
express all seven, though two collapse a pair onto one class — Foundation has
no informational button colour, so info renders as secondary rather than
cf-ui inventing a class Foundation's own CSS does not define. A vocabulary cut
down to the poorest framework would make the richer ones useless; one built for
the richest would make info mean nothing in Foundation.
normal is the middle step, not "whatever the framework does with no
class". Usually those coincide and it maps to no class at all — a real entry in
the map, not a gap, since the completeness test treats a missing key as the
failure.
Two things follow from that, and both are visible to consumers:
Sometimes normal needs a class. Bulma's .tag defaults to its smallest
step, so a normal badge maps to is-medium in order to line up with a normal
button. The shared vocabulary is the whole point — size="normal" has to mean
one thing across primitives, or sharing it buys nothing.
Sometimes size does nothing. Bootstrap and Foundation ship no badge size
classes, so all three values map to no class and a Bootstrap badge renders
identically at every size. cf-ui does not fake this with font-size utilities:
Bootstrap's fs-* scale bottoms out larger than a default badge, so it
cannot express "small" and would be a wrong-direction lie. Switching
CF_UI_THEME can therefore change whether size has any effect on a given
primitive.
What an axis value becomes, and why it matters¶
The third column of that table is not decoration. It is the one fact that decides two separate things.
Whether the axis has a per-theme class map. Class-valued axes do. level
and type do not, because there is nothing to map — the value is the tag
name, or the attribute value.
Whether an empty value is allowed. For a class-valued axis it is: the class
is simply not emitted, and you get a valid element that is merely unstyled.
That tolerance is load-bearing rather than lax — Django resolves a missing
context variable to "", and every cotton wrapper forwards its props
unconditionally, so a guard that rejected empty values would reject ordinary
renders.
For level and type an empty value is not benign, so it raises:
<c-cf.heading :level="section.depth"> # depth is None
PrimitiveConfigError: heading needs a level — it becomes the element's tag,
so an empty one renders malformed markup rather than unstyled markup.
Pass one of: 1, 2, 3, 4, 5, 6
Without that check the render succeeds and emits <h class="title">Section</h>.
<h> is not an element. Browsers parse it as an unknown inline, and it still
picks up the title class — so it looks like a heading while being absent
from the document outline and unreachable by screen-reader heading navigation.
It is the exact silent failure the guard exists to convert into an exception,
in the one axis where an empty value degrades to malformed rather than
unstyled.
type is the same shape with a sharper edge. HTML's missing-value default for
an invalid <button type> is submit — so type="sumbit" does not render
an inert button, it renders one that submits the enclosing form. cf-ui defaults
to button precisely to avoid that, and an open set would have let a typo undo
it silently.
Colour is not a channel on its own¶
A danger badge and a success badge differ only by hue. If the badge's own
text does not carry the meaning, that is a
WCAG 1.4.1
failure, and it is not one cf-ui can fix from inside the component:
<!-- Fails: "4" means nothing without the colour -->
<c-cf.badge variant="danger">4</c-cf.badge>
<!-- Passes: the text carries it -->
<c-cf.badge variant="danger">4 failed</c-cf.badge>
The variant sets the colour. Making the meaning legible without it is the caller's obligation.
Which primitive takes which axis¶
| Primitive | variant |
size |
state |
emphasis |
level |
type |
|---|---|---|---|---|---|---|
button |
● | ● | ● | ● | ||
badge |
● | ● | ||||
heading |
● | ● | ● | |||
label |
● | |||||
icon |
● | |||||
box |
● | |||||
prose |
● |
label and icon take no variant on purpose. A label's colour belongs to
the field it labels and an icon's to whatever contains it; giving either its
own would create two sources of truth for one colour.
box takes variant and nothing else — a container's size is its content's
business, and no shipped framework models a "state" for a plain box. prose
takes only size: a typographic reset has no colour of its own, and giving
it variant would mean colouring every nested element it wraps rather than
the one thing this primitive actually decides.
heading separates level from size because they answer different
questions — level is the document outline (<h1>…<h6>, which screen
readers navigate by) and size is how big it looks. Coupling them forces a
choice between a correct outline and a correct visual hierarchy.
emphasis started life as a subtitle=True boolean and was promoted, because
in Bulma title and subtitle are alternatives rather than additive. As a
boolean outside the class map it made classes_for() report "title is-4"
for a call that actually rendered subtitle is-4 — and, worse, its daisyUI
class (opacity-60) was invisible to cf_ui_primitives.json, so a Tailwind
build would have tree-shaken it away. A prop that decides which classes are
emitted belongs in the map, where the parity test can see it.
The axes are not all equally useful on every theme¶
This is the honest cost of one vocabulary across five frameworks, and it is worth knowing before you reach for an axis:
| Primitive | Where an axis does less than you'd expect |
|---|---|
badge |
size is inert on Bootstrap and Foundation — neither ships badge size modifiers |
icon |
size is inert on Foundation, which has no icon wrapper and no font-size scale. On Bootstrap the scale is mixed: small is em-relative and composes inside a btn-sm, large is absolute and does not |
label |
size has no small step on Foundation |
button |
info renders as secondary on Foundation, which ships five button colours and no informational one |
box |
variant is inert on Bulma — .box has no colour modifier at all |
prose |
size is inert on Bootstrap, Foundation, and Fomantic — their base typography already applies document-wide, so there is no reset step left for size to control |
cf-ui does not paper over these with utilities that reach the wrong values.
Bootstrap's fs-* scale, for instance, bottoms out larger than a default
badge, so using it for size="small" would be a wrong-direction lie rather
than a fallback.
How they compose¶
Primitives nest through the slot, never through props:
There is deliberately no icon="fa-plus" prop on Cf:Button. An icon prop
would have to accept either a class name or raw markup: the first ties cf-ui to
one icon vendor, and the second is an unescaped-markup prop on the single most
frequently used component in the package. The slot has neither problem.
Once the icon primitive lands it occupies that same slot position, adding
sizing and alignment around the glyph — it does not change how the two nest.
That is the whole reason the contract is settled for the set before any of it
is built.
Cf:Button / <c-cf.button>¶
| Prop | Default | Notes |
|---|---|---|
variant |
"neutral" |
See the vocabulary above |
size |
"normal" |
|
state |
"normal" |
loading renders the framework's spinner; disabled is described below |
href |
"" |
Non-empty renders an <a>; empty renders a <button> |
type |
"button" |
button submit reset. Only applies to the <button> form |
full_width |
false |
|
extra_class / class |
"" |
extra_class in JinjaX, class in cotton — class is a Python reserved word |
| slot | — | The label |
Disabled links¶
state="disabled" with an href renders an <a> without an href
attribute, carrying role="link" and aria-disabled="true".
An <a> cannot be disabled. Leaving the href on and styling it grey
produces a control that is still focusable, still activatable by Enter, and
still followed on middle-click — disabled to sighted mouse users only.
Dropping the attribute is what actually removes it from the tab order, and
aria-disabled is what tells assistive technology why.
If you need a disabled control that is genuinely inert, prefer omitting href
so it renders a real <button disabled>.
Cf:Badge / <c-cf.badge>¶
| Prop | Default | Notes |
|---|---|---|
variant |
"neutral" |
|
size |
"normal" |
Inert on Bootstrap and Foundation |
extra_class / class |
"" |
|
| slot | — | The badge text |
Renders a <span> on every theme, with no role and no ARIA. role="status"
would make it a live region announced on every re-render, which is wrong for
a static badge — its slot text is its accessible name.
There is no pill prop. It is idiomatic in Bootstrap and Bulma and absent
from daisyUI, Foundation and Fomantic, so it would be inert on three of five;
reach for class="rounded-pill" instead.
Cf:Heading / <c-cf.heading>¶
| Prop | Default | Notes |
|---|---|---|
level |
"2" |
1–6. Renders <h1>…<h6>. Semantics only; cannot be empty |
size |
"normal" |
Visual size, independent of level |
emphasis |
"normal" |
subtle for a de-emphasised heading |
extra_class / class |
"" |
|
| slot | — | The heading text |
<!-- An h2 in the outline that looks small -->
<c-cf.heading level="3" size="small">Section</c-cf.heading>
Cf:Label / <c-cf.label>¶
| Prop | Default | Notes |
|---|---|---|
size |
"normal" |
No small step on Foundation |
for_id |
"" |
Becomes the for attribute. Not for — see below |
required |
false |
Renders an indicator announced as "required" |
extra_class / class |
"" |
|
| slot | — | The label text |
The attribute is for_id, not for
for is a Python reserved word, so JinjaX cannot express it. django-cotton
can, which is the hazard: <c-cf.label for="email"> would render valid
HTML with no for attribute at all and a silently broken label/control
association.
for is therefore a declared alias — primitives.ALIASES maps it to
for_id, the wrapper forwards it into the guard, and it raises naming the
prop that works:
What the alias table does and does not catch¶
ALIASES covers props whose natural HTML spelling differs from cf-ui's —
the name a caller will reach for first. Today that is one entry, and adding a
theme or a primitive does not change it.
It does not catch arbitrary misspellings. <c-cf.icon labl="Delete item">
still renders a decorative icon with the accessible name silently dropped,
because django-cotton discards any attribute a component does not declare —
for every cotton component in every project, not just cf-ui's. That is a
property of the template engine, not something a wrapper can close from the
inside. Treat cf-ui's prop names as exact.
The JinjaX side behaves the same way for a different reason: an undeclared
attribute lands in attrs, which cf-ui's templates do not render.
The required indicator is a single <span role="img" aria-label="required">*</span>
rather than a hidden-glyph-plus-visually-hidden-text pair, because Fomantic
2.9.3 ships no visually-hidden utility — that approach would have given four
themes one accessibility mechanism and the fifth another. Fomantic's own
.required::after is deliberately not used: generated content disappears under
print and high-contrast stylesheets, and its announcement is inconsistent.
Cf:Icon / <c-cf.icon>¶
| Prop | Default | Notes |
|---|---|---|
size |
"normal" |
Inert on Foundation; mixed scale on Bootstrap |
label |
"" |
Empty ⇒ decorative. Non-empty ⇒ the accessible name |
extra_class / class |
"" |
|
| slot | — | Your <i>, <svg>, or <span> |
The accessibility decision is most of what this component buys you, and it is the part hand-written icon markup usually gets wrong:
<!-- Decorative: skipped by screen readers entirely -->
<c-cf.icon><i class="fas fa-star"></i></c-cf.icon>
<span aria-hidden="true">…</span>
<!-- Meaningful: announced as "Delete item", and not descended into -->
<c-cf.icon label="Delete item"><i class="fas fa-trash"></i></c-cf.icon>
<span role="img" aria-label="Delete item">…</span>
The two are mutually exclusive by construction, so a labelled icon can never
also be aria-hidden. role="img" matters as much as the label: it makes the
wrapper a leaf for assistive technology, so the caller's meaningless glyph
markup is not descended into.
cf-ui ships no icons and depends on no icon set. Bootstrap assumes Bootstrap Icons, Bulma assumes Font Awesome, daisyUI assumes nothing — there is no class-level abstraction spanning all five, and adopting one would make a UI kit choose its consumers' icon vendor.
Cf:Box / <c-cf.box>¶
| Prop | Default | Notes |
|---|---|---|
variant |
"neutral" |
Inert on Bulma — see below |
extra_class / class |
"" |
|
| slot | — | Arbitrary content |
box is a plain bordered or elevated container: one element, no imposed
inner structure. It is named box, not surface — box is Bulma's actual
class name for this element, and surface is Material Design vocabulary
that none of the five shipped frameworks use.
Bootstrap has no box component, so cf-ui composes one from utilities —
border rounded p-3, with variant mapping to border-primary and so on.
card was considered and rejected: it imposes a header/body/footer structure
a plain box's caller cannot opt out of. Foundation renders callout, with
variant mapped onto its five colours (primary secondary success warning
alert); Foundation spells danger alert and ships no informational hue, so
info folds onto secondary — the same substitution badge already makes
on this theme. On daisy the border colour lives entirely in variant,
neutral included (border-base-300): splitting a default colour into
base and an override into variant would put two border-color utilities
of equal specificity on one element, leaving Tailwind's emission order,
rather than this map, to decide which one renders.
Bulma: variant is inert¶
Bulma ships no colour modifier for .box. has-background-* classes exist,
but they set a saturated background without touching text colour — routing
variant through them would make a themed box unreadable rather than
themed. cf-ui leaves the axis inert on this theme instead of faking it: a
Bulma box looks the same regardless of variant.
Fomantic: the hue trap¶
Fomantic renders a box as ui segment plus a hue — blue grey green yellow
red teal. This is the theme where the obvious implementation is wrong, and
worth saying plainly because it is the kind of trap this doc exists to
record.
primary and secondary are not colours on a Fomantic segment — they
are its emphasis variation. .ui.primary.segment renders a subdued
treatment, not a brand-coloured fill. Mapping variant="primary" onto the
literal word primary would compile, look plausible, and silently produce
the wrong result on the one theme where the variant name happens to match a
real Fomantic class. cf-ui maps primary to blue and secondary to
grey instead — real hues, not the words that look like they should work.
Cf:Prose / <c-cf.prose>¶
| Prop | Default | Notes |
|---|---|---|
size |
"normal" |
Inert on Bootstrap, Foundation, and Fomantic — see below |
extra_class / class |
"" |
|
| slot | — | Arbitrary rich content: headings, paragraphs, lists, tables |
prose is a typographic reset block: it styles nested h1–h6, p, ul,
and table without requiring a class on each one. It takes size and
nothing else, for the reason given above the axis table.
It is named prose, not content, for two concrete reasons. content
collides with Bulma's own .content class — the exact class this component
maps to on that theme — and content is already the prop name every Jinja
primitive uses for slot text, so the JinjaX spelling would have been a
Content component taking a content prop — self-contradictory. (Written
out rather than shown as a tag: tests/unit/test_docs_samples.py resolves
every Cf: tag in these docs against the real catalogue, so a component that
deliberately does not exist cannot be illustrated as one.)
<Cf:Prose size="large">
<h2>Release notes</h2>
<p>Everything in this block gets typographic styling for free.</p>
</Cf:Prose>
<c-cf.prose size="large">
<h2>Release notes</h2>
<p>Everything in this block gets typographic styling for free.</p>
</c-cf.prose>
On Bulma it maps to .content, with is-small/is-large for size — the
reference implementation the other four themes are measured against.
Bootstrap and Foundation: no class, and that's correct¶
Both frameworks emit no class at all for prose, and size is inert on
both. That is not a gap. Bootstrap's Reboot and Foundation's base typography
style bare h1-h6, p, and ul document-wide already, so the reset this
component exists to scope is already in effect everywhere on the page —
there is nothing for a class to add.
Fomantic: a real gap, and a different reason¶
Fomantic emits no class either, but not for the same reason as the two above, and this one is a genuine limitation rather than a redundant no-op.
Fomantic does style bare h1-h5 and p globally, so headings and
paragraphs inside a Cf:Prose block come out right with no wrapper class
needed. It ships no bare ul, ol, or table rule at all — that
styling lives on .ui.list and .ui.table, applied to the element itself
rather than inherited from an ancestor. A list or table inside a prose block
therefore renders with browser defaults, and no scoping class exists that
would fix it.
cf-ui does not paper over this by authoring its own Fomantic typography
reset — that would mean shipping component CSS this package has never
shipped, and guessing at a framework's type scale instead of using the
framework's own. If you need a styled list or table inside a Fomantic prose
block, reach for the framework's own element-level classes directly: use
<c-cf.table> for tables, and put ui list on <ul>/<ol>.
daisyUI: requires @tailwindcss/typography¶
On daisy, prose maps to the prose class, with prose-sm/prose-lg for
size — but prose is not a daisyUI class and not a Tailwind core class.
It comes from @tailwindcss/typography, a separate plugin declared a
requirement for this one component on this one theme. See
DaisyUI theme for how to add it.
Without the plugin, prose is simply an unrecognised class name: the block
renders unstyled, with no error and no warning. That is the same benign
class-valued failure mode AXIS_KINDS already tolerates for an empty axis
value — it just arrives here from a missing plugin instead of an empty prop.
Escaping¶
The contract in Escaping applies unchanged: every cf-ui Jinja
template wraps its body in {% autoescape true %}, so a hostile prop is
escaped whatever the surrounding environment does, and a prop carrying real
markup must be markupsafe.Markup.
Primitives take no markup props at all. Everything that can carry markup arrives through the slot, where it is the caller's own template output and governed by the caller's own escaping policy:
| Surface | Escaped by cf-ui | Notes |
|---|---|---|
variant, size, state, level |
n/a | Validated against a closed set before they reach the template |
href, type, extra_class |
Yes | Ordinary attribute values |
| slot content | Caller's policy | Where an icon's <svg> or <i> goes |
icon is the one primitive whose slot is expected to hold markup, and it is
still the slot rather than a prop — so passing a Markup value is a visible,
per-call decision at the call site rather than a package default.
prose is the other one, and the stakes are higher: its whole purpose is to
wrap caller-supplied HTML, often multiple elements of it, rather than one
<i> tag. The mechanism is identical — slot-based, still inside cf-ui's own
{% autoescape true %} block — but the rule above is not sufficient on its
own to say what's safe to put there. See
Escaping: the prose contract for the
explicit statement.
Why the classes are written out longhand¶
Open a theme partial and you will find every class spelled literally:
class="btn{% if variant == 'primary' %} btn-primary{% elif variant == 'danger' %} btn-error{% endif %}"
rather than the obvious btn-{{ variant }}. This is required, not stylistic.
daisyUI compiles through Tailwind, whose scanner reads source text — a
class name assembled at render time is never seen, so it is removed from the
build. No error, no warning, an unstyled page as the only symptom. Emitting the
class from Python has exactly the same effect, which is why
cf_ui.primitives.classes_for() exists for tests and consumers but is not used
by the shipped templates.
The cost is that the vocabulary lives in two places: primitives.py and ten
template files. tests/unit/test_primitives.py binds them in both directions —
every class in the map must appear literally in the templates, and every class
in the templates must be in the map or in a short, explicit list of layout
utilities. Neither copy can move alone.
That duplication is also why every primitive template calls the guard once:
A literal {% if %} chain has no else. Handed variant="purple" it matches
nothing and renders a correct-looking element with no colour. The guard turns
that silence into an exception at the call site.
The guard needs the installer
cf_ui_validate is bound by install_cf_ui (FastAPI and Litestar) and by
the cf_ui template library (Django), alongside the axis globals. A
primitive rendered on a bare Catalog() with no installer raises
'cf_ui_validate' is undefined — the same requirement
cf_ui_root_attrs() already has.
Regenerating¶
primitives.py is the single source of truth;
static/cf_ui/cf_ui_primitives.json is a build product of it — the
machine-readable export of the vocabularies and class maps, mirroring
cf_ui_axes.json. Edit the module, then:
and commit both. A drift test fails the build otherwise.
The primitives layer is complete¶
Every primitive registered in PRIMITIVES — button, badge, heading,
label, icon, box, prose — ships templates on all five themes in both
engines. Tier 1 (badge, heading, label, icon) landed in #53; Tier 2
(box, prose), documented above, is #54. Tier 3 was grid, and the
decision on it is below: cf-ui does not ship one.
Layout is out of scope¶
cf-ui does not ship a grid, and will not. Decided in #55; this section is the reasoning, so it does not have to be re-litigated.
Why not¶
The premise the ticket was written on turned out to be false. It assumed four of the five themes ship a 12-column grid and only daisyUI is the odd one out. Checked against upstream documentation, the frameworks do not agree on the two things a shared grid vocabulary would have to fix:
| Theme | Columns | Breakpoint model | Tiers |
|---|---|---|---|
| Bootstrap 5.3 | 12 | min-width | (none) · sm 576 · md 768 · lg 992 · xl 1200 · xxl 1400 |
| Bulma 1.0 | 12 | mobile is max-width, the rest min-width |
mobile <768 · tablet 769 · desktop 1024 · widescreen 1216 · fullhd 1408 |
| Foundation 6.7 | 12 | min-width | small 0 · medium 640 · large 1024 · xlarge 1200 · xxlarge 1440 |
| Fomantic 2.9 | 16 | min-width | mobile 320 · tablet 768 · computer 992 · large monitor 1200 · widescreen 1920 |
| Tailwind 3 (daisy) | 12 utilities | min-width | sm 640 · md 768 · lg 1024 · xl 1280 · 2xl 1536 |
Two facts in that table kill the abstraction on their own.
Fomantic's grid is 16 columns. Not a different spelling of the same idea — a different idea. A column asking for a span of 6 would mean half the row on four themes and three-eighths of it on Fomantic. There is no span vocabulary that is correct on both, and the failure is silent: the page still renders, just at the wrong width. Every other axis cf-ui absorbs maps n names onto n spellings of one concept. This one does not have one concept underneath it.
Bulma's mobile is a max-width cap, not a rung on a min-width ladder.
So a canonical breakpoint set is not merely a numbers problem where cf-ui
picks the least-wrong value. It is a shape problem: at="mobile" means
"from here up" on four themes and "below here" on Bulma, and a mapping
that inverts a condition is not a mapping.
The numbers themselves are also not as tidy as "everyone disagrees" would
suggest — Bootstrap's md and Tailwind's md are both 768px, and Bulma's
tablet lands one pixel away. That incidental overlap is worth stating
because it is the strongest thing the build case had, and it is not
enough: agreement at one tier out of five, between two of five themes,
does not make the ladders interchangeable.
daisyUI's asymmetry, which the ticket led with, is real but is the least
of it. daisyUI ships no grid at all and defers to Tailwind utilities, so a
daisy grid would emit grid-cols-12 and col-span-6 — utility classes,
not framework component classes, and inert for any consumer who installed
daisyUI's compiled CSS without a Tailwind build.
Why not a reduced version either¶
The obvious retreat is a non-responsive grid: a fixed set of column counts, no breakpoint axis. It covers most of the measured uses in the reference repo and sidesteps the breakpoint disagreement entirely.
It is still the wrong thing to ship, for two reasons that point the same way. A three-column layout that stays three columns on a phone is not a simpler grid, it is a broken one — so nobody would use the reduced version as-is; they would reach for the responsive escape hatch immediately, which is the part that does not work. And Fomantic's 16 columns break the reduced version just as thoroughly as the full one, because the column count is not the responsive axis. Cutting responsiveness removes the smaller problem and leaves the larger one.
The Tailwind literal-class constraint is the third strike rather than the
first: 12 spans × 5 breakpoints is 60 spelled-out branches per template,
duplicated in primitives.py for the parity test, for a component whose
semantics are wrong on one theme in five.
What this costs, stated plainly¶
cf-ui's design principle is that switching CSS frameworks means changing
CF_UI_THEME in one place. Layout is the exception. A consumer who
writes <div class="columns"><div class="column is-6"> has written Bulma
into their templates, and flipping CF_UI_THEME to bootstrap leaves
every component correct and every page's layout broken.
That is a real limit and cf-ui would rather name it than paper over it with a component that is silently wrong on Fomantic.
What to do instead¶
- Write layout in the framework's own vocabulary. It is the one place a direct dependency is cheaper than an adapter, because the adapter cannot be correct.
- If you need theme-switchable layout, use CSS Grid or flexbox
directly.
display: grid; grid-template-columns: repeat(3, 1fr)with a media query is framework-independent, needs no build step, and is about as much code as the component would have been at the call site. - If you are migrating to Tailwind or daisyUI, write Tailwind utilities. That is what the reference-repo migration behind #52 does, and a cf-ui grid would have been pure indirection on the far side of it.
Layout being out of scope is a decision, not a gap. Please don't file it as a bug.