#7553·ariakit

Explore extending disclosure style recipes in the nav recipes

Author: diegohazCreated Sep 18, 2026Updated Sep 18, 2026
Labelsfeatureui

This issue is the implementation specification for letting the nav and list style recipes extend the disclosure style recipes. The exploration is finished: every design decision below was settled by the maintainer, and the whole contract was prototyped green on main at 2fc48a8. Further contract changes are made by editing this body.

Problem

While fixing the nav end bar offset that the disclosure body padding caused (#7554), the fix had to publish the space under the button as a --disclosure-body-offset custom property on the disclosure root, set from the nav disclosure's style attribute, because a pbs-* utility added to navDisclosureContentBody and the pbs-* utility that disclosureContentBody already emits land on the same element, and only Tailwind's emitted order decides which one wins. That race is a symptom of how the nav and disclosure recipes are combined today.

The nav recipes in packages/ariakit-ui/src/styles/nav.ts (navDisclosure, navButton, navDisclosureContentBody) do not extend the disclosure recipes in packages/ariakit-ui/src/styles/disclosure.ts (disclosure, disclosureButton, disclosureContentBody). Instead, the React components in packages/ariakit-ui/src/components/nav.ariakit.react.tsx render the Disclosure framework components and spread the nav recipe's jsx() output over them. The recipes were kept separate so the nav components could reuse the disclosure components instead of re-implementing their behavior in nav. The list components in list.ariakit.react.tsx do the same over the same framework components, and NavButton does it over Button.

Two cv outputs meet on one element through splitProps, which claims the incoming className and style and appends them to the framework recipe's own output. Conflicts between the two recipes are then resolved by the stylesheet cascade, not by clava's extend order, which applies a base once and before the child. The current code pays for that in several places:

  • navButton marks its gap utility important because the disclosure button spends its own --gap channel on the same property: gap-[calc(var(--nav-row-gap,--spacing(3))+var(--px)-var(--py))]!.
  • navDisclosure writes --disclosure-gap and, since #7554, --disclosure-body-offset through the style attribute, because that is the only way to beat the disclosure root's own classes, as its comments say. listDisclosure does the same for --disclosure-ps and --disclosure-icon.
  • navDisclosureContentBody extends frameBase for the body's radius and padding channels, while disclosureContentBody extends edge and owns the padding utilities (p-(--ak-frame-padding), ps-(--disclosure-body-ps), pbs-*). One element's geometry is split across two recipes that cannot see each other's output.
  • While prototyping the end bar fix, a pbs-[max(...)] utility on the nav body beat the disclosure body's pbs-[calc(...)] utility only because Tailwind sorts same-property arbitrary candidates by comparing the raw candidate strings. Renaming the value to calc(max(...)) made it lose.
  • NavDisclosureContentBody forces $layer="transparent", NavDisclosureButton forces $gap="none", NavDisclosure sets $contentPadding, $rounded, $p, and $layer as parameter defaults, NavButton forces $rounded="lg" and $lightnessOffset={false}, and ListDisclosure forces $rounded="unset" and $p="unset". Defaults for disclosure variants live in the React components instead of in defaultVariants, because those variants belong to a recipe the wrapper's recipe does not extend.
  • Each wrapper's props type joins two variant surfaces, for example NavDisclosureButtonProps extends DisclosureButtonProps, VariantProps<typeof navButton>, and each wrapper runs its own splitProps before the framework component runs another one.

The obstacle to writing extend: [disclosureContentBody] in the nav recipe today is that Disclosure, DisclosureButton, DisclosureContent, and DisclosureContentBody each apply their own recipe internally. A nav recipe that extended the disclosure recipe would emit the disclosure contribution a second time on the same element, and the nav component's splitProps would claim the shared variant props before the framework component sees them.

Goals

  • One recipe per element: the nav and list disclosure root, button, and body, and the standalone nav button, each get their classes and style from a single cv that extends the corresponding base recipe.
  • No ! flags or style-attribute overrides whose only purpose is to beat the base recipe's class on the same element.
  • Defaults for base variants ($contentPadding, $rounded, $p, $layer, $gap, $lightnessOffset) expressed as defaultVariants of the extending recipe rather than as React parameter defaults or forced props.
  • Variant contracts inherited through extend, so each wrapper's props type is the VariantProps of one recipe.
  • The disclosure behavior stays implemented once, in the framework components. The nav and the list customize only style and their own composition.

Non-goals

  • Changing what the nav, the list, or the disclosure look like. Every style attribute and every computed style must stay the same, with the one exception listed under "What must not change".
  • Replacing clava or Tailwind, or adding a class-merging step that rewrites class strings at render time.
  • Loosening clava's variantKeys and propKeys types.
  • Adding the prop to framework components other than the four named below.

Contract

1. Four framework components accept a generic recipe prop

Disclosure, DisclosureButton, DisclosureContentBody (in disclosure.ariakit.react.tsx) and Button (in button.ariakit.react.tsx) accept an optional prop named recipe. DisclosureContent does not get it, because no wrapper adds a recipe to it.

  • The constraint uses built-in types only: R extends Pick<typeof base, "getVariants"> = typeof base. This is the shape clava uses for its own VariantProps constraint. There is no helper module and no custom helper type.
  • The component runs one splitProps and one jsx() against the recipe it was given, or its own recipe when none was given.
  • The component reads base variants through one inline cast, with this comment, in each of the four components.
  • The four props types become generic type aliases, because an interface cannot extend VariantProps<R> while R is a type parameter (TS2312). Their non-variant members move to exported own-props interfaces (DisclosureOwnProps, DisclosureButtonOwnProps) so that every existing JSDoc comment survives unchanged.
  • Everything else in the components stays as it is: the store, the provider, data-open, the split to $split mapping, the slots, the label and description wiring, the indicator, and the $disabled derivation in Button.
typescript
// disclosure.ariakit.react.tsx
export interface DisclosureOwnProps {
  /** Custom button element or props to render a `DisclosureButton`. */
  button?: React.ReactNode | DisclosureButtonProps;
  /** Custom content element or props to render a `DisclosureContent`. */
  content?: React.ReactElement | DisclosureContentProps;
  decoration?: React.ReactNode;
  split?: boolean;
}

export type DisclosureProps<
  R extends Pick<typeof disclosure, "getVariants"> = typeof disclosure,
> = Omit<ak.RoleProps<"div">, "content"> &
  Pick<ak.DisclosureProviderProps, "open" | "setOpen" | "defaultOpen"> &
  VariantProps<R> &
  DisclosureOwnProps & {
    /** The recipe applied to the root in place of `disclosure`. It must extend it. */
    recipe?: R;
  };

export function Disclosure<
  R extends Pick<typeof disclosure, "getVariants"> = typeof disclosure,
>({ recipe, open, setOpen, defaultOpen, split, button, content, decoration, ...props }: DisclosureProps<R>) {
  // The recipe accepts every base variant, which is all the component reads.
  const styles = (recipe ?? disclosure) as typeof disclosure;
  const [variantProps, rest] = splitProps(props, styles);
  // ...unchanged, with `styles` wherever `disclosure` was applied...
}

2. The nav and list recipes extend the base recipes

styles/nav.ts:

  • navDisclosure: extend: [disclosure]. Its style block is removed. defaultVariants are $contentPadding: true, $rounded: "lg", $p: 2, $layer: "transparent", plus the two channel variants from section 3. The comments that explained the React parameter defaults move onto the matching defaults.
  • New navDisclosureButton = cv({ extend: [disclosureButton], class: "whitespace-normal" }). It has no gap utility and no !: the inherited $gap: "auto" already spends --disclosure-gap, which the nav root sets.
  • navButton (standalone rows through Button): extend: [button], defaultVariants: { $rounded: "lg", $lightnessOffset: false, $gap: "none" }, and its own gap utility without the !, the way navLink already does it. Its header comment changes, because it is no longer shared with the disclosure button.
  • navDisclosureContentBody: extend: [disclosureContentBody, frameBase], with $layer: "transparent" added to its defaultVariants and the comment moved from the React component. Its classes stay.

styles/list.ts:

  • listDisclosure: extend: [disclosure, listRow]. The order is significant and needs a comment: listRow comes last, so its $rounded: "xl" and $p: "var(--list-item-padding)" defaults win over the disclosure's. That replaces the $rounded="unset" and $p="unset" props. Its style block is removed. defaultVariants are $layer: "transparent" (restated, because listRow turns the layer off and the disclosure root needs the transparent one) plus the two channel variants from section 3.
  • listDisclosureButton: extend: [disclosureButton]. listDisclosureContentBody: extend: [disclosureContentBody]. Their classes stay.

3. Channels become variants of disclosure

Every custom property that a nav or list root sets through a recipe style entry only to beat a class of the disclosure root becomes a function variant of disclosure that writes the same custom property through style, the way $iconSize already does. The rendered style attribute does not change. What changes is that the base recipe owns the knob, types it, and a caller can override it with a prop.

Variant on disclosure Writes Set in defaultVariants of Value
$slotGap(value?: string | number) --disclosure-gap navDisclosure "var(--nav-row-gap, calc(var(--spacing) * 3))"
$bodyOffset(value?: string | number) --disclosure-body-offset navDisclosure "var(--nav-gap, calc(var(--spacing) * 1))"
$indent(value?: string | number) --disclosure-ps listDisclosure "calc(var(--py) + (var(--px) - var(--py) + 1lh) * var(--list-guide))"
$leadingIcon(value?: boolean) --disclosure-icon as "1" or "0" listDisclosure false
typescript
$slotGap(value?: string | number) {
  if (value == null) return;
  return { style: { "--disclosure-gap": getSpacingValue(value) } };
},
$leadingIcon(value?: boolean) {
  if (value == null) return;
  return { style: { "--disclosure-icon": value ? "1" : "0" } };
},
  • Unset, a variant emits nothing, so the class defaults on the root keep working: [--disclosure-gap:max(...)], [--disclosure-body-offset:0px], [--disclosure-ps:initial], and the :has() detection of a leading slot.
  • Declare the four variants after $iconSize, in the order of the table. That keeps the key order of the emitted style attributes identical to today's.
  • $leadingIcon stays a style entry on purpose: the flag has to beat a :has() rule that no class can beat by order.
  • The comments in disclosure.ts that say a nav or a list sets a channel "through the style attribute" are updated to name the variant.
  • The names were chosen to avoid two hazards found in the final prototype. A root variant named $gap would share its name, with another type, with the $gap map variant of button and control and with the $gap of nav, which makes a recipe that extends both disclosure and button ill-typed. A variant named $ps would read like padding on the root beside textFrame's $px, but it indents the button's start padding and the body.

4. The wrappers become thin

NavDisclosure, NavDisclosureButton, NavDisclosureContentBody, NavButton, ListDisclosure, ListDisclosureButton, and ListDisclosureContentBody keep their public names, their shorthands (button="..."), and their own behavior: the nav context (NavDisclosureRoot stays), the NavButtonContent label wrapping, the indicator defaults, guide, the list marker and label composition, the checked and progress props, and the decoration guide. They lose their splitProps call, their .jsx() spread, their parameter defaults, and their forced variant props. NavDisclosureContent and ListDisclosureContent are unchanged, because they add no recipe.

  • A wrapper's props type stays an interface over the framework props with recipe omitted: Omit<DisclosureProps<typeof navDisclosure>, "recipe">. On the button and body types, the Omit is what lets the root narrow its button and content slots. On the root it hides the fixed recipe.
  • The spread order carries the contract and gets one comment per wrapper: recipe first, the caller's props second, the composed slots last.
  • The NavDisclosure comment that explained why its defaults were parameters and not props is removed. defaultVariants solves that case structurally: passing every variant as undefined produces the same class and style as passing nothing.
typescript
export interface NavDisclosureProps
  extends Omit<DisclosureProps<typeof navDisclosure>, "recipe"> {
  button?: React.ReactNode | NavDisclosureButtonProps;
  content?: React.ReactElement | NavDisclosureContentProps;
}

export function NavDisclosure({ button, content, ...props }: NavDisclosureProps) {
  return (
    <Disclosure
      // The order carries the contract: the recipe first, the caller's props
      // second, the composed slots last.
      recipe={navDisclosure}
      {...props}
      button={createOptionalRender(NavDisclosureButton, button)}
      content={createRender(NavDisclosureContent, content)}
      render={<NavDisclosureRoot render={props.render} />}
    />
  );
}

export function ListDisclosure({ button, content, decoration, ...props }: ListDisclosureProps) {
  return (
    <Disclosure
      recipe={listDisclosure}
      {...props}
      decoration={<>{decoration}<ListItemGuide /></>}
      button={createOptionalRender(ListDisclosureButton, button)}
      content={createRender(ListDisclosureContent, content)}
    />
  );
}

export function NavButton(props: NavButtonProps) {
  return <Button recipe={navButton} {...props} />;
}

Examples

typescript
// A caller overrides a nav default: one class for $rounded lands, the nav's
// default is not emitted.
<NavDisclosure $rounded="md" button="Docs">...</NavDisclosure>

// A caller moves a channel on one row. The variant writes the custom property
// on the root, and the button and body follow it.
<NavDisclosure $slotGap={4} $bodyOffset={2} button="Docs">...</NavDisclosure>
<ListDisclosure $leadingIcon button="Item">...</ListDisclosure>

// A consumer builds a sidebar disclosure over the same behavior.
const sidebarDisclosure = cv({
  extend: [disclosure],
  variants: { $tone: { quiet: "opacity-80", loud: "font-semibold" } },
  defaultVariants: { $p: 3, $rounded: "xl", $slotGap: "var(--sidebar-gap)" },
});

export function SidebarDisclosure(
  props: Omit<DisclosureProps<typeof sidebarDisclosure>, "recipe">,
) {
  return <Disclosure recipe={sidebarDisclosure} {...props} />;
}

<SidebarDisclosure $tone="quiet" />; // the new variant is typed
<SidebarDisclosure $nope />; // type error

What must not change

Measured in the final prototype against a baseline taken on the same commit, and expected from the implementation:

  • The style attribute of every affected element is identical, character for character and in key order. The nav root keeps --frame-padding, --disclosure-gap, --disclosure-body-offset. The list root keeps --frame-padding, --disclosure-ps, --disclosure-icon: 0.
  • No class token appears twice on any element. Today the nav disclosure button repeats w-full, justify-start, overflow-clip, text-start, and the list root repeats ak-frame.
  • No ! gap utility remains. On the nav disclosure button, the inherited [--gap:calc(var(--disclosure-gap)+var(--px)-var(--py))] gap-(--gap) replaces it. On the standalone nav button, the same utility without ! is the only gap utility, because $gap: "none" emits nothing.
  • Chrome computed styles of the root, button, content, and body of a nav row and of a list row are identical (padding, radius, background, gap, and the --disclosure-* channels), with one exception. The nav disclosure button's row gap goes from about 18px to 4px, because the old ! shorthand set both axes and the inherited $gapY: "auto" now applies. The button is a non-wrapping flex row, so nothing moves on screen, and the plain disclosure button and the list button already compute 4px. Note it in the recipe comment.
  • The class order inside the class attribute changes: extend places a wrapper's own classes before the base's variant output instead of last. CSS ignores it. Do not write rules that assume the wrapper's class comes last.

Known limits

  • The Pick constraint means "does not contradict the base", not "extends it". It rejects a recipe with no variant name in common with the base, and accepts one that shares a name, including the recipe of another disclosure part: <Disclosure recipe={navDisclosureContentBody} /> compiles, and a missing $split is then dropped silently at runtime. The wrappers in this repository are the only callers today. The exact check, a type that walks the recipe's extend list and leaves clava's key arrays exact, is tracked in ariakit/clava#538. Adopting it later changes each constraint and nothing else, because its accepting branch is this same Pick.
  • The cast types the recipe as the base, so splitProps types a wrapper's extra variant keys into the rest props, while at runtime the recipe's own keys move them out. The runtime is the correct one, and the rest props are spread onto an element that tolerates the type.
  • Two class-against-class competitions exist today and stay, because they are not about the nav: justify-center from control beside justify-start on every disclosure button, and p-(--ak-frame-padding) beside ak-frame plus ak-frame-p-(--frame-padding) on the nav body. On the list body, the variant-gated in-[.list]:pbs-[...] still wins over the body's own pbs-* by Tailwind's order, as today.
  • Four framework props types change from interface to type. Declaration merging on them is no longer possible, and nothing in the repository does it. The generated public reference is not affected: the jsdoc loader reads packages/ariakit-react and packages/ariakit-solid only, and the legacy extractor has no @ariakit/ui entry. Only the nav and the list consume the four types.

Test obligations

  • Types: whole-repository pnpm tsc, which emits declarations. A type test or probe that an unknown variant on a wrapper is rejected, that recipe on a wrapper is rejected, that a consumer recipe's new variant is typed on the framework component, and that createOptionalRender(NavDisclosureButton, button), createRender(NavDisclosureContent, content), and createRender(DisclosureContentBody, body, { $prose }) still infer.
  • happy-dom, in app/src/sandbox/ariakit-ui-nav/test.ts and app/src/sandbox/ariakit-ui-list/test.ts: the disclosure root, button, and bod