BtnGroupDropDown
Keep the primary action in reach and its alternatives close by. A native disclosure with a button-styled trigger, an open content slot, and independent menu styling.
BtnGroupDropDownUse it beside a primary action inside BtnGroup, or on its own for a compact set of links and actions. The trigger is a native summary inside details; menu items retain their own button or link semantics.
Playground
Explore all thirteen props. Trigger appearance and menu appearance are independent: try a strong accent trigger with a neutral surface menu. Open the preview after changing a setting to see the result. Its links navigate to real component guides.
An empty accessible-name field lets the visible label name the trigger. In icon-only mode, the component falls back to the label. The playground starts with a neutral menu; omitting menuTone instead inherits the surrounding tone.
<RegorApp id="dropdown-playground" src="./dropdown-playground.ts"/> import {
type BtnGroupDropDownAlign,
type BtnIconPosition,
type BtnSize,
type ComponentVariant,
type ComponentVariantMode,
defineBadgeComponents,
defineBtnGroupComponents,
defineButtonComponents,
defineFlexComponents,
defineFormComponents,
defineFormInputField,
defineFormSelectField,
defineGridComponents,
defineIconComponents,
type FormSelectOption,
} from '@purestack/ts-components'
import { SEMANTIC_TONES, type SemanticTone } from '@purestack/ts-style'
import {
iconoir_calendar,
iconoir_more_horiz,
lucide_chevron_down,
} from '@purestack/ts-svg-icons'
import { batch, createApp, defineComponent, html, type Ref, ref } from 'regor'
export interface DropdownPlayground {
triggerLabel: Ref<string>
triggerAriaLabel: Ref<string>
triggerIcon: Ref<string>
triggerPosition: Ref<BtnIconPosition>
triggerIconOnly: Ref<boolean>
triggerTone: Ref<SemanticTone>
triggerSize: Ref<BtnSize>
triggerVariant: Ref<ComponentVariant>
triggerMode: Ref<ComponentVariantMode>
panelTone: Ref<SemanticTone>
panelVariant: Ref<ComponentVariant>
panelMode: Ref<ComponentVariantMode>
panelAlign: Ref<BtnGroupDropDownAlign>
tones: FormSelectOption[]
variants: FormSelectOption[]
sizes: FormSelectOption[]
modes: FormSelectOption[]
positions: FormSelectOption[]
alignments: FormSelectOption[]
icons: FormSelectOption[]
reset: () => void
}
const dropdownPlaygroundTemplate = html`<Flex direction="column">
<Flex justify="center" align="center" class="p-4">
<BtnGroupDropDown
:label="triggerLabel"
:ariaLabel="triggerAriaLabel"
:icon="triggerIcon"
:iconPosition="triggerPosition"
:iconOnly="triggerIconOnly"
:tone="triggerTone"
:size="triggerSize"
:variant="triggerVariant"
:variantMode="triggerMode"
:menuTone="panelTone"
:menuVariant="panelVariant"
:menuVariantMode="panelMode"
:align="panelAlign"
>
<div class="p-2 fs-xs tone-text-muted">Component guides <Badge>3</Badge></div>
<BtnLink href="/components/actions/buttons/" variant="subtle">Buttons</BtnLink>
<BtnLink href="/components/actions/btn-link/" variant="subtle">BtnLink</BtnLink>
<BtnLink href="/components/actions/badge/" variant="subtle">Badge</BtnLink>
</BtnGroupDropDown>
</Flex>
<Grid columns="1" columnsSm="2" columnsLg="3">
<FormInputField id="dropdown-label" label="Trigger label" :model="triggerLabel"/>
<FormInputField id="dropdown-aria-label" label="Accessible name" :model="triggerAriaLabel"/>
<FormSelectField id="dropdown-icon" label="Icon" :model="triggerIcon" :options="icons"/>
<FormSelectField id="dropdown-position" label="Icon position" :model="triggerPosition" :options="positions"/>
<FormSelectField id="dropdown-tone" label="Trigger tone" :model="triggerTone" :options="tones"/>
<FormSelectField id="dropdown-size" label="Trigger size" :model="triggerSize" :options="sizes"/>
<FormSelectField id="dropdown-variant" label="Trigger variant" :model="triggerVariant" :options="variants"/>
<FormSelectField id="dropdown-mode" label="Trigger variant mode" :model="triggerMode" :options="modes"/>
<FormSelectField id="dropdown-menu-tone" label="Menu tone" :model="panelTone" :options="tones"/>
<FormSelectField id="dropdown-menu-variant" label="Menu variant" :model="panelVariant" :options="variants"/>
<FormSelectField id="dropdown-menu-mode" label="Menu variant mode" :model="panelMode" :options="modes"/>
<FormSelectField id="dropdown-align" label="Menu alignment" :model="panelAlign" :options="alignments"/>
</Grid>
<Flex wrap="true" align="center">
<FormCheck id="dropdown-icon-only" label="Icon-only trigger" :checked="triggerIconOnly"/>
<Btn variant="link" @click="reset">Reset playground</Btn>
</Flex>
</Flex>`
function createDropdownPlayground(): DropdownPlayground {
const triggerLabel = ref('Explore')
const triggerAriaLabel = ref('')
const triggerIcon = ref('lucide:chevron-down')
const triggerPosition = ref<BtnIconPosition>('end')
const triggerIconOnly = ref(false)
const triggerTone = ref<SemanticTone>('accent')
const triggerSize = ref<BtnSize>('md')
const triggerVariant = ref<ComponentVariant>('solid')
const triggerMode = ref<ComponentVariantMode>('stateful')
const panelTone = ref<SemanticTone>('neutral')
const panelVariant = ref<ComponentVariant>('surfaceAlt')
const panelMode = ref<ComponentVariantMode>('stateless')
const panelAlign = ref<BtnGroupDropDownAlign>('end')
const options = (values: string[]): FormSelectOption[] =>
values.map((value) => ({ label: value, value }))
return {
triggerLabel,
triggerAriaLabel,
triggerIcon,
triggerPosition,
triggerIconOnly,
triggerTone,
triggerSize,
triggerVariant,
triggerMode,
panelTone,
panelVariant,
panelMode,
panelAlign,
tones: options(SEMANTIC_TONES),
variants: options([
'solid',
'surface',
'surfaceAlt',
'spotlight',
'glass',
'flat',
'flatAlt',
'flatSolid',
'outlineFill',
'outline',
'subtle',
'subtleBtn',
'link',
'sheen',
'underline',
'rail',
'bracket',
'none',
]),
sizes: options(['sm', 'md', 'lg']),
modes: options(['stateful', 'stateless']),
positions: options(['start', 'end']),
alignments: options(['start', 'end']),
icons: options([
'lucide:chevron-down',
'iconoir:more-horiz',
'iconoir:calendar',
]),
reset: () =>
batch(() => {
triggerLabel('Explore')
triggerAriaLabel('')
triggerIcon('lucide:chevron-down')
triggerPosition('end')
triggerIconOnly(false)
triggerTone('accent')
triggerSize('md')
triggerVariant('solid')
triggerMode('stateful')
panelTone('neutral')
panelVariant('surfaceAlt')
panelMode('stateless')
panelAlign('end')
}),
}
}
const icons: Record<string, string> = {
'lucide:chevron-down': lucide_chevron_down,
'iconoir:more-horiz': iconoir_more_horiz,
'iconoir:calendar': iconoir_calendar,
}
const component = defineComponent<DropdownPlayground>(
dropdownPlaygroundTemplate,
{ context: createDropdownPlayground },
)
createApp(
{
components: {
DropdownPlayground: component,
...defineBadgeComponents(),
...defineBtnGroupComponents(),
...defineButtonComponents(),
...defineFlexComponents(),
...defineGridComponents(),
...defineFormComponents(),
...defineFormInputField(),
...defineFormSelectField(),
...defineIconComponents((name) => {
if (!icons[name]) throw new Error(`Icon is not registered: ${name}`)
return icons[name]
}),
},
},
{
selector: 'app#dropdown-playground',
template: html`<DropdownPlayground/>`,
},
) Split publish action
A primary action with nearby publishing choices, adapted from the framework's Button Group Samples. Publish immediately, mark the draft ready, or schedule the preview for tomorrow. Each choice updates the visible state. Published previews disable publishing and scheduling until you return to draft or reset.
This example models the workflow locally; it does not publish content or schedule a background job. The action handler closes the disclosure and returns focus to its trigger.
<RegorApp id="publish-actions" src="./publish-actions.ts"/> import {
defineBadgeComponents,
defineBtnGroupComponents,
defineButtonComponents,
defineFlexComponents,
defineIconComponents,
definePanelComponents,
} from '@purestack/ts-components'
import type { SemanticTone } from '@purestack/ts-style'
import {
iconoir_calendar,
iconoir_check,
iconoir_send,
lucide_chevron_down,
} from '@purestack/ts-svg-icons'
import {
type ComputedRef,
computed,
createApp,
defineComponent,
html,
type Ref,
ref,
} from 'regor'
export type ReleaseState = 'Draft' | 'Ready' | 'Scheduled' | 'Published'
export interface PublishActions {
releaseState: Ref<ReleaseState>
releaseTone: ComputedRef<SemanticTone>
isPublished: ComputedRef<boolean>
feedback: ComputedRef<string>
publish: () => void
choose: (state: 'Draft' | 'Ready' | 'Scheduled', event: Event) => void
reset: () => void
}
const publishActionsTemplate = html`<Panel tone="neutral" variant="surfaceAlt" class="p-4 overflow-visible">
<Flex direction="column" align="start" class="gap-3">
<div>
<div class="fs-h2 fw-700">Release notes</div>
<p class="tone-text-muted mb-0">Keep the main action close to its publishing choices.</p>
</div>
<Flex wrap="true" align="center" role="status">
<Badge :tone="releaseTone">{{ releaseState }}</Badge>
<span>{{ feedback }}</span>
</Flex>
<BtnGroup :wrap="true" role="group" aria-label="Publishing actions">
<Btn tone="accent" icon="iconoir:send" :disabled="isPublished" @click="publish">Publish now</Btn>
<BtnGroupDropDown label="More" tone="accent" menuTone="neutral" align="start">
<Btn variant="subtle" icon="iconoir:calendar" :disabled="isPublished" @click="choose('Scheduled', $event)">Schedule for tomorrow</Btn>
<Btn variant="subtle" icon="iconoir:check" :disabled="isPublished" @click="choose('Ready', $event)">Mark ready</Btn>
<Btn variant="subtle" @click="choose('Draft', $event)">Return to draft</Btn>
</BtnGroupDropDown>
</BtnGroup>
<Btn variant="link" @click="reset">Reset release</Btn>
</Flex>
</Panel>`
function createPublishActions(): PublishActions {
const releaseState = ref<ReleaseState>('Draft')
const isPublished = computed(() => releaseState() === 'Published')
const releaseTone = computed<SemanticTone>(() =>
releaseState() === 'Draft'
? 'neutral'
: releaseState() === 'Scheduled'
? 'info'
: 'success',
)
const feedback = computed<string>(
() =>
({
Draft: 'Changes stay in this example.',
Ready: 'Reviewed and ready to publish.',
Scheduled: 'Preview scheduled for tomorrow at 09:00.',
Published: 'Preview release published.',
})[releaseState()],
)
return {
releaseState,
releaseTone,
isPublished,
feedback,
publish: () => releaseState('Published'),
choose: (state, event) => {
releaseState(state)
const menu = (event.currentTarget as HTMLElement).closest('details')
if (menu) {
menu.open = false
menu.querySelector('summary')?.focus()
}
},
reset: () => releaseState('Draft'),
}
}
const icons: Record<string, string> = {
'lucide:chevron-down': lucide_chevron_down,
'iconoir:send': iconoir_send,
'iconoir:calendar': iconoir_calendar,
'iconoir:check': iconoir_check,
}
const component = defineComponent<PublishActions>(publishActionsTemplate, {
context: createPublishActions,
})
createApp(
{
components: {
PublishActions: component,
...defineBadgeComponents(),
...defineBtnGroupComponents(),
...defineButtonComponents(),
...defineFlexComponents(),
...definePanelComponents(),
...defineIconComponents((name) => {
if (!icons[name]) throw new Error(`Icon is not registered: ${name}`)
return icons[name]
}),
},
},
{ selector: 'app#publish-actions', template: html`<PublishActions/>` },
) Link menu with context
The default slot accepts links, buttons, and ordinary markup. Add a short heading or description to explain the choices. Navigation remains native, so readers can copy a destination or open it in another tab.
Compact triggers
Use an icon-only trigger when space is tight and its context is clear. Give it a specific accessible name. Set size on each trigger or button independently; a group does not forward its children's appearance props.
Explore
<Panel tone="neutral" variant="surfaceAlt" class="p-4 overflow-visible">
<Flex wrap="true" align="center" class="gap-3">
<BtnGroup role="group" aria-label="Release resources">
<BtnLink href="/components/actions/badge/" tone="success" size="sm" icon="iconoir:check">Status guide</BtnLink>
<BtnGroupDropDown size="sm" label="Explore" variant="outline" menuTone="neutral" align="start">
<BtnLink href="/components/actions/badge/#tones" variant="subtle">Status tones</BtnLink>
<BtnLink href="/components/actions/badge/#live-release-status" variant="subtle">Release checklist</BtnLink>
</BtnGroupDropDown>
</BtnGroup>
<BtnGroup role="group" aria-label="Action resources">
<BtnLink href="/components/actions/buttons/" tone="info" size="sm" icon="iconoir:code">Button guide</BtnLink>
<BtnGroupDropDown
size="sm"
icon="iconoir:more-horiz"
:iconOnly="true"
ariaLabel="More button resources"
variant="outline"
menuTone="neutral"
>
<BtnLink href="/components/actions/buttons/#playground" variant="subtle">Button playground</BtnLink>
<BtnLink href="/components/actions/buttons/#api-reference" variant="subtle">Button API</BtnLink>
</BtnGroupDropDown>
</BtnGroup>
</Flex>
</Panel> For a working icon-only action menu with selection state, try the selection toolbar.
Placement and wrapping
align anchors the menu to the trigger's start or end edge. It does not align the whole button group. PureStack's menu runtime adjusts placement to the viewport, can place a menu above the trigger, and limits its height when space is tight.
The row below wraps at narrow widths. Its surrounding Panel uses overflow-visible so it cannot clip the open menu. Use this utility on any preview container whose normal styles hide overflow.
<Panel tone="neutral" variant="surfaceAlt" class="p-4 overflow-visible">
<Flex direction="column" class="gap-3">
<div class="fw-700">Choose a starting point</div>
<BtnGroup :wrap="true" role="group" aria-label="Documentation destinations">
<BtnLink href="/components/" tone="accent" icon="iconoir:rocket">All components</BtnLink>
<BtnGroupDropDown label="Start aligned" align="start" variant="outline" menuTone="neutral">
<BtnLink href="/components/actions/buttons/" variant="subtle">Button documentation</BtnLink>
<BtnLink href="/components/actions/btn-link/" variant="subtle">Link documentation</BtnLink>
</BtnGroupDropDown>
<BtnGroupDropDown label="End aligned" align="end" variant="outline" menuTone="neutral">
<BtnLink href="/components/actions/btn-group/" variant="subtle">Group documentation</BtnLink>
<BtnLink href="/components/actions/badge/" variant="subtle">Badge documentation</BtnLink>
</BtnGroupDropDown>
</BtnGroup>
</Flex>
</Panel> Behavior and accessibility
- Tab reaches the summary. Enter or Space toggles the native disclosure. Tab then moves through its links and buttons in document order.
- PureStack's built-in menu runtime closes open menus on Escape or an outside click. Opening a menu closes the other open menus.
- Selecting an item inside the panel does not automatically close the menu. The publish example closes it explicitly and restores focus after completing an action.
- Use a specific
ariaLabelfor icon-only triggers, such as “More message actions”. Visible triggers normally use their label as their accessible name. - This is a disclosure containing ordinary controls. It does not add ARIA menu roles, arrow-key selection, or a focus trap. Keep native child semantics unless you implement a complete alternative keyboard model.
- An icon-only trigger falls back to
label, then “More actions”, for its accessible name. A name that describes the actual choices is more useful. - There is no dropdown
disabledprop. Disable individual actions with Btn, or omit the disclosure when none of its content is useful.
API reference
<BtnGroupDropDown label="More"> A native <details> with a <summary> trigger, thirteen public props, and one default slot. Trigger and panel variants have separate defaults.
All props accept values or Regor refs. Register defineBtnGroupComponents() and defineIconComponents() in browser applications, along with the families used in the slot. Even the default trigger uses an icon: register lucide:chevron-down unless you supply another one.
Trigger content
label
string - Default
More
The visible trigger text. Omitted or empty values use “More”. In icon-only mode, a non-empty label also supplies the fallback accessible name.
<BtnGroupDropDown label="Explore">
<BtnLink href="/components/" variant="subtle">Component guides</BtnLink>
</BtnGroupDropDown> icon
string - Default
lucide:chevron-down
An icon name from the configured registry. Omitted or empty values use the default chevron; an empty string does not hide the icon.
lucide:chevron-downiconoir:more-horiziconoir:calendar<BtnGroupDropDown label="Actions" icon="iconoir:more-horiz">
<BtnLink href="/components/actions/buttons/" variant="subtle">Button guide</BtnLink>
</BtnGroupDropDown> iconPosition
BtnIconPosition - Default
end
Places the icon before or after the visible label. Unlike Btn and BtnLink, this trigger defaults to a trailing icon.
startendStart
End
iconOnly
boolean - Default
false
Hides the visible label and uses the compact icon-only treatment. Use a boolean binding and give the trigger a specific accessible name.
<BtnGroupDropDown icon="iconoir:more-horiz" :iconOnly="true" ariaLabel="More component guides">
<BtnLink href="/components/" variant="subtle">All components</BtnLink>
</BtnGroupDropDown> ariaLabel
string - Default
automatic for iconOnly
Sets aria-label on the summary. An explicit non-empty value takes precedence. Without one, icon-only triggers use the supplied label or “More actions”; visible triggers use their text.
This prop targets the trigger. A native aria-label attribute on the component root targets the details element instead.
Trigger appearance
tone
SemanticTone - Default
inherited
The trigger's semantic palette. It does not set the menu palette; use menuTone for that. An omitted tone inherits its surroundings.
neutralaccentfeaturesecondarycustomghostinfosuccesswarningdanger<BtnGroupDropDown label="Explore" tone="accent" menuTone="neutral">
<BtnLink href="/components/" variant="subtle">Component guides</BtnLink>
</BtnGroupDropDown> size
BtnSize - Default
md (base size)
Sets the trigger's text, padding, and icon dimensions. Set the size of menu items independently.
smmdlgSmall
Medium
Large
variant
ComponentVariant - Default
solid
The trigger's visual treatment. Every shared variant is supported; the menu panel has its own variant.
solidsurfacesurfaceAltspotlightglassflatflatAltflatSolidoutlineFilloutlinesubtlesubtleBtnlinksheenunderlinerailbracketnone<BtnGroupDropDown label="Resources" variant="outline" menuVariant="surfaceAlt">
<BtnLink href="/components/" variant="subtle">Component guides</BtnLink>
</BtnGroupDropDown> variantMode
ComponentVariantMode - Default
stateful
Controls whether the trigger's variant includes its shared hover and active styles. Stateless mode keeps only the base treatment; the native disclosure still opens and closes.
statefulstatelessThe dropdown's open-state focus ring is separate from the variant mode.
Menu panel
align
BtnGroupDropDownAlign - Default
end
Preferred alignment of the menu against its trigger. The menu runtime adjusts its final position to fit the viewport. This prop is independent of BtnGroup's start, center, and end alignment.
startend<BtnGroupDropDown label="Explore" align="start">
<BtnLink href="/components/" variant="subtle">Component guides</BtnLink>
</BtnGroupDropDown> Slots, attributes, and events
Default slot
The menu contents. Direct Btn and BtnLink children fill the menu width and align their labels to the start. Plain markup can provide headings, descriptions, or metadata. The trigger text comes from label, not the slot.
Native attributes
id, class, and other unconsumed attributes reach the root details element. Its native open state determines visibility.
Events and closing
The root exposes native details events such as toggle. Each child owns its click or navigation behavior. There is no custom selection event or automatic close-on-select behavior.
Runtime
ts-ssg includes its standard menu runtime for outside-click dismissal, Escape dismissal, and viewport placement. Native details still toggles without JavaScript. A browser app mounted with RegorApp uses that same page runtime.
For a complete action handler that updates state, closes the disclosure, and restores focus, open publish-actions.ts in the split publish example's source tabs.