
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
toggle-groupuiGroup of `Toggle` buttons that act as a single-select or multi-select control. Use for view-mode pickers (grid/list), text-format toolbars, and any "pick one of N" button bar.
Also available for React ->$pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/toggle-group.json$npx shadcn-vue@latest add https://uipkge.dev/r/vue/toggle-group.json$yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/toggle-group.json$bunx shadcn-vue@latest add https://uipkge.dev/r/vue/toggle-group.jsonnpx shadcn-vue@latest add @uipkge/toggle-groupInstalls to:app/components/ui/toggle-group/| Name | Type / Values | Default | Required |
|---|---|---|---|
class | HTMLAttributes['class'] | — | optional |
variant | 'default''outline' | — | optional |
size | 'default''sm''lg' | — | optional |
spacing | number | 0 | optional |
asChild | boolean | — | optional |
as | string | object | — | optional |
type | 'single''multiple' | — | optional |
modelValue | string | string[] | — | optional |
defaultValue | string | string[] | — | optional |
disabled | boolean | — | optional |
loop | boolean | — | optional |
orientation | 'horizontal''vertical' | — | optional |
rovingFocus | boolean | — | optional |
dir | 'ltr''rtl' | — | optional |
animatedSliding selection indicator for single-select (default true). Multi-select keeps item chrome. | boolean | true | optional |
Type aliases from this item's source — use them to shape the data you pass in.
ToggleGroupContexttype ToggleGroupContext {
variant?: Ref<'default' | 'outline' | undefined> | 'default' | 'outline'
size?: Ref<'default' | 'sm' | 'lg' | undefined> | 'default' | 'sm' | 'lg'
spacing?: Ref<number | undefined> | number
}<script setup lang="ts">
import type { ToggleGroupRootEmits } from 'reka-ui'
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ToggleGroupRoot, useForwardPropsEmits } from 'reka-ui'
import { computed, nextTick, onBeforeUnmount, onMounted, provide, ref, toRef, watch } from 'vue'
import { cn } from '@/lib/utils'
// Inlined unions: SFC compiler can't extract runtime props from
// `VariantProps<typeof toggleVariants>['...']`. Same for the
// reka-ui `ToggleGroupRootProps` (no exports.types). Inline the
// surface we expose.
const props = withDefaults(
defineProps<{
class?: HTMLAttributes['class']
variant?: 'default' | 'outline'
size?: 'default' | 'sm' | 'lg'
spacing?: number
asChild?: boolean
as?: string | object
type?: 'single' | 'multiple'
modelValue?: string | string[]
defaultValue?: string | string[]
disabled?: boolean
loop?: boolean
orientation?: 'horizontal' | 'vertical'
rovingFocus?: boolean
dir?: 'ltr' | 'rtl'
/** Sliding selection indicator for single-select (default true). Multi-select keeps item chrome. */
animated?: boolean
}>(),
{
spacing: 0,
animated: true,
},
)
const emits = defineEmits<ToggleGroupRootEmits>()
// Provide reactive refs so items pick up live variant/size/spacing changes.
provide('toggleGroup', {
variant: toRef(props, 'variant'),
size: toRef(props, 'size'),
spacing: toRef(props, 'spacing'),
})
const delegatedProps = reactiveOmit(props, 'class', 'size', 'variant', 'animated')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
// Sliding pill only for single-select. Multi-select paints per-item surfaces.
const indicatorActive = computed(() => props.animated !== false && props.type !== 'multiple')
const listEl = ref<HTMLElement | null>(null)
const indicatorStyle = ref<Record<string, string>>({
opacity: '0',
})
let ro: ResizeObserver | null = null
let mo: MutationObserver | null = null
let firstPosition = true
function resolveListEl(node: unknown): HTMLElement | null {
if (!node) return null
if (node instanceof HTMLElement) return node
const el = (node as { $el?: unknown }).$el
return el instanceof HTMLElement ? el : null
}
function setListRef(node: unknown) {
listEl.value = resolveListEl(node)
}
function motionSafeTransition() {
if (typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
return 'none'
}
return firstPosition
? 'none'
: 'transform 220ms cubic-bezier(0.22, 1, 0.36, 1), width 220ms cubic-bezier(0.22, 1, 0.36, 1), height 220ms cubic-bezier(0.22, 1, 0.36, 1), border-radius 220ms cubic-bezier(0.22, 1, 0.36, 1)'
}
function updateIndicator() {
if (!indicatorActive.value) return
const root = listEl.value
if (!root) return
const active = root.querySelector<HTMLElement>('[data-slot="toggle-group-item"][data-state="on"]')
if (!active) {
indicatorStyle.value = { opacity: '0' }
return
}
const listRect = root.getBoundingClientRect()
const activeRect = active.getBoundingClientRect()
const left = activeRect.left - listRect.left + root.scrollLeft
const top = activeRect.top - listRect.top + root.scrollTop
const transition = motionSafeTransition()
indicatorStyle.value = {
width: `${activeRect.width}px`,
height: `${activeRect.height}px`,
transform: `translate3d(${left}px, ${top}px, 0)`,
borderRadius: getComputedStyle(active).borderRadius,
opacity: '1',
transition,
}
firstPosition = false
}
function unbindObservers() {
ro?.disconnect()
mo?.disconnect()
ro = null
mo = null
}
function bindObservers() {
const root = listEl.value
if (!root || !indicatorActive.value) return
unbindObservers()
ro = new ResizeObserver(() => updateIndicator())
ro.observe(root)
root.querySelectorAll('[data-slot="toggle-group-item"]').forEach((el) => ro!.observe(el))
mo = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === 'childList') {
root.querySelectorAll('[data-slot="toggle-group-item"]').forEach((el) => ro?.observe(el))
}
}
nextTick(updateIndicator)
})
mo.observe(root, {
attributes: true,
attributeFilter: ['data-state'],
subtree: true,
childList: true,
})
updateIndicator()
}
onMounted(() => {
nextTick(() => {
if (!listEl.value) {
requestAnimationFrame(() => bindObservers())
} else {
bindObservers()
}
})
})
onBeforeUnmount(() => {
unbindObservers()
})
watch(indicatorActive, (on) => {
firstPosition = true
if (on) nextTick(() => bindObservers())
else {
unbindObservers()
indicatorStyle.value = { opacity: '0' }
}
})
watch(
() => [props.spacing, props.size, props.variant, props.orientation] as const,
() => {
firstPosition = true
nextTick(updateIndicator)
},
)
</script>
<template>
<ToggleGroupRoot
:ref="setListRef"
v-slot="slotProps"
data-uipkge
data-slot="toggle-group"
:data-size="size"
:data-variant="variant"
:data-spacing="spacing"
:data-animated="indicatorActive ? 'true' : 'false'"
:style="{
'--gap': spacing,
}"
v-bind="forwarded"
:class="
cn(
'group/toggle-group relative flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs',
props.class,
)
"
>
<span
v-if="indicatorActive"
data-slot="toggle-group-indicator"
aria-hidden="true"
class="bg-accent pointer-events-none absolute top-0 left-0 z-0 shadow-xs will-change-transform"
:style="indicatorStyle"
/>
<slot v-bind="slotProps" />
</ToggleGroupRoot>
</template>
<script setup lang="ts">
import type { HTMLAttributes, Ref } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ToggleGroupItem, useForwardProps } from 'reka-ui'
import { computed, inject } from 'vue'
import { cn } from '@/lib/utils'
import { toggleVariants } from '@/components/ui/toggle'
type ToggleGroupContext = {
variant?: Ref<'default' | 'outline' | undefined> | 'default' | 'outline'
size?: Ref<'default' | 'sm' | 'lg' | undefined> | 'default' | 'sm' | 'lg'
spacing?: Ref<number | undefined> | number
}
// Inlined unions: SFC compiler can't extract runtime props from
// indexed-access types. Same for reka-ui's ToggleGroupItemProps.
const props = defineProps<{
class?: HTMLAttributes['class']
variant?: 'default' | 'outline'
size?: 'default' | 'sm' | 'lg'
asChild?: boolean
as?: string | object
value: string
disabled?: boolean
}>()
const context = inject<ToggleGroupContext>('toggleGroup')
function unwrap<T>(v: Ref<T> | T | undefined): T | undefined {
if (v && typeof v === 'object' && 'value' in (v as object)) return (v as Ref<T>).value
return v as T | undefined
}
const effectiveVariant = computed(() => unwrap(context?.variant) || props.variant)
const effectiveSize = computed(() => unwrap(context?.size) || props.size)
const effectiveSpacing = computed(() => unwrap(context?.spacing))
const delegatedProps = reactiveOmit(props, 'class', 'size', 'variant')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<ToggleGroupItem
v-slot="slotProps"
data-uipkge
data-slot="toggle-group-item"
:data-variant="effectiveVariant"
:data-size="effectiveSize"
:data-spacing="effectiveSpacing"
v-bind="forwardedProps"
:class="
cn(
toggleVariants({
variant: effectiveVariant,
size: effectiveSize,
}),
// z-10 keeps label/icons above the sliding indicator. When the parent
// has data-animated=true (single-select), on-state surface lives on the
// indicator — suppress item bg so the pill can slide cleanly.
'relative z-10 w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10',
'group-data-[animated=true]/toggle-group:data-[state=on]:bg-transparent group-data-[animated=true]/toggle-group:data-[state=on]:hover:bg-transparent',
// first/last-of-type (not first/last-child): sliding indicator is a sibling span
// and must not steal end-cap rounding or the outline left border.
'data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first-of-type:rounded-l-md data-[spacing=0]:last-of-type:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first-of-type:border-l',
props.class,
)
"
>
<slot v-bind="slotProps" />
</ToggleGroupItem>
</template>
export { default as ToggleGroup } from './ToggleGroup.vue'
export { default as ToggleGroupItem } from './ToggleGroupItem.vue'
Raw manifest:https://uipkge.dev/r/vue/toggle-group.json