Pseudo-Elements

NEW
A badge, an underline, a custom bullet, the colour of selected text — nine pseudo-elements as nested props, and content that quotes itself.

Decoration without another element

A ::before is a box the browser gives you for free, and the reason to want one is that it costs no markup: a badge, a focus ring that overshoots, a gradient underline that grows on hover. Nine of them are nesting keys now — before, after, placeholder, selection, marker, firstLine, firstLetter, backdrop and fileButton — and every prop, state, breakpoint and theme works inside them.
The nine keys
JSX
<Box
  before={{ width: 2, bgColor: 'indigo-500' }}   // ::before  — content comes with it
  after={{ content: 'attr(data-suffix)' }}       // ::after
  placeholder={{ color: 'slate-400' }}           // ::placeholder
  selection={{ bgColor: 'indigo-200' }}          // ::selection
  marker={{ color: 'indigo-500' }}               // ::marker
  firstLine={{ fontWeight: 600 }}                // ::first-line
  firstLetter={{ fontSize: 48 }}                 // ::first-letter
  backdrop={{ bgColor: 'slate-900' }}            // ::backdrop
  fileButton={{ bgColor: 'indigo-500' }}         // ::file-selector-button
/>

content, which you no longer have to remember

A generated element with no content renders nothing at all — the single commonest way a ::before is silently missing. So declaring before or after supplies content: '' unless you say otherwise, and the pseudo-element exists in exactly the states you styled it in: put it under hover and it appears on hover.
What content takes
JSX
<Box before={{ content: 'empty' }} />                      // content: ''   (the default, spelled out)
<Box before={{ content: 'New' }} />                        // content: "New"  — text is quoted for you
<Box after={{ content: '"Step " counter(step)' }} />       // written as CSS, so a sequence works
<Box after={{ content: 'attr(data-suffix)' }} />           // attr(), counter(), url(), var()
<Box before={{ content: 'none' }} />                       // and off again

A badge is one prop

The tag below is a single <Button>: the count rides in a data-* attribute and after draws it, so there is no second element to position, no wrapper, and nothing to keep in sync.
after + attr()
JSX
<Button
  props={{ 'data-count': 3 }}
  position="relative"
  after={{
    content: 'attr(data-count)',
    position: 'absolute',
    top: -2,
    right: -2,
    width: 5,
    height: 5,
    borderRadius: 5,
    bgColor: 'rose-500',
    color: 'white',
    fontSize: 11,
    display: 'grid',
    placeContent: 'center',
  }}
>
  Inbox
</Button>

An underline that grows, and only exists on hover

before nested inside hover is a pseudo-element that appears in that state — including the content it needs. Nested the other way round, before: { hover: … }, it is the same one compound selector: .x:hover::before either way, and one class.
before + hover
Hover this label
JSX
<Box
  tag="span"
  position="relative"
  before={{
    position: 'absolute',
    bottom: -1,
    left: 0,
    height: 0.5,
    width: 0,
    bgImage: 'gradient-primary',
    transitionDuration: 250,
  }}
  hover={{ before: { width: 'fit' } }}
>
  Hover this label
</Box>

And it follows a state your own code sets

Everything that nests around a prop nests around a pseudo-element: a breakpoint, a theme, a group, and the dataAttr/ariaAttr/has/not variants. The element is appended to whatever they build, because CSS allows exactly one and it has to come last.
dataAttr + after
JSX
<Button
  props={{ 'data-saved': saved ? 'yes' : 'no' }}
  onClick={() => setSaved(!saved)}
  dataAttr={{
    'saved=yes': { after: { content: ' ✓', color: 'emerald-500' } },
    'saved=no': { after: { content: ' •', color: 'slate-400' } },
  }}
>
  Save draft
</Button>

placeholder, selection, marker

The other six style something the browser already renders, so they take the properties CSS lets them and ignore the rest — a ::selection is colours, not layout. On a <Textbox> the name placeholder means both things: a string is the attribute, an object is the styles. Want both, the text goes in props, where every attribute goes.
placeholder, selection, marker

Select this sentence to see ::selection restyled.

  • One
  • Two
JSX
<Textbox
  props={{ placeholder: 'Search projects…' }}
  placeholder={{ color: 'indigo-400', fontStyle: 'italic' }}
/>

<P selection={{ bgColor: 'indigo-500', color: 'white' }}>Select this sentence.</P>

<Ul marker={{ color: 'indigo-500', fontSize: 18 }}>
  <Li>One</Li>
  <Li>Two</Li>
</Ul>

One per selector, and the element goes last

CSS allows a single pseudo-element in a compound selector, at the end. That is a slot rather than a list here, so nesting one inside another is a type error, and a merged component style that manages it anyway is dropped instead of emitting ::before::after, which matches nothing. It is also why the element lands on the target of a group selector: .card:hover .x::before, never .card:hover::before .x.
Where it lands
CSS
/* hover={{ before: { opacity: 1 } }} */
.x:hover::before { opacity: 1 }

/* md={{ dataAttr: { 'state=open': { after: { opacity: 1 } } } }} */
@media (min-width: 768px) { .y[data-state="open"]::after { opacity: 1 } }

/* hoverGroup={{ card: { before: { opacity: 1 } } }} — the state is the card's, the element is ours */
.card:hover .z::before { opacity: 1 }

Text is quoted, and a value that cannot be is refused

content is the one prop whose value is text a caller wrote, and text becomes rule text. So a plain string is quoted and escaped (a quote, a backslash, a newline), a value written as CSS is scanned instead — every quote closed, every parenthesis balanced, no ;, } or @ outside a string — and one that fails produces no rule and no class name, exactly like an unmatched prop value.