Bubble
Conversational content in a bubble: seven variants, start/end alignment, grouping and floating reactions.
import { Bubble, BubbleContent, BubbleGroup, BubbleReaction, BubbleReactions,} from "@/components/ui/bubble";import { IconPlaceholder } from "@/edmi/icon-placeholder";
const variants = [ "default", "secondary", "muted", "tinted", "outline", "ghost", "destructive",] as const;
export default function Demo() { return ( <div className="flex w-full max-w-md flex-col gap-4"> <BubbleGroup> {variants.map((variant) => ( <Bubble key={variant} variant={variant}> <BubbleContent>{variant} bubble</BubbleContent> </Bubble> ))} </BubbleGroup> <BubbleGroup> <Bubble align="end"> <BubbleContent>Run it and send me the transaction.</BubbleContent> </Bubble> </BubbleGroup> <Bubble variant="secondary" className="mb-3"> <BubbleContent>The keeper just rebalanced MAG4.</BubbleContent> <BubbleReactions> <BubbleReaction active>👍 4</BubbleReaction> <BubbleReaction>🚀 2</BubbleReaction> <BubbleReaction aria-label="Add reaction"> <IconPlaceholder lucide="SmileIcon" tabler="IconMoodSmile" hugeicons="SmileIcon" phosphor="SmileyIcon" remixicon="RiEmotionLine" className="size-3" /> </BubbleReaction> </BubbleReactions> </Bubble> </div> );}<script setup lang="ts">import { Bubble, BubbleContent, BubbleGroup, BubbleReaction, BubbleReactions } from "@/components/ui/bubble";import { SmileIcon } from "@lucide/vue";
const variants = ["default", "secondary", "muted", "tinted", "outline", "ghost", "destructive"] as const;</script>
<template> <div class="flex w-full max-w-md flex-col gap-4"> <BubbleGroup> <Bubble v-for="variant in variants" :key="variant" :variant="variant"> <BubbleContent>{{ variant }} bubble</BubbleContent> </Bubble> </BubbleGroup> <BubbleGroup> <Bubble align="end"> <BubbleContent>Run it and send me the transaction.</BubbleContent> </Bubble> </BubbleGroup> <Bubble variant="secondary" class="mb-3"> <BubbleContent>The keeper just rebalanced MAG4.</BubbleContent> <BubbleReactions> <BubbleReaction active>👍 4</BubbleReaction> <BubbleReaction>🚀 2</BubbleReaction> <BubbleReaction aria-label="Add reaction"> <SmileIcon class="size-3" /> </BubbleReaction> </BubbleReactions> </Bubble> </div></template><script lang="ts"> import * as Bubble from "$lib/components/ui/bubble";
const variants = ["default", "secondary", "muted", "tinted", "outline", "ghost", "destructive"] as const;</script>
<div class="flex w-full max-w-md flex-col gap-4"> <Bubble.Group> {#each variants as variant (variant)} <Bubble.Root {variant}> <Bubble.Content>{variant} bubble</Bubble.Content> </Bubble.Root> {/each} </Bubble.Group> <Bubble.Group> <Bubble.Root align="end"> <Bubble.Content>Run it and send me the transaction.</Bubble.Content> </Bubble.Root> </Bubble.Group> <Bubble.Root variant="secondary" class="mb-3"> <Bubble.Content>The keeper just rebalanced MAG4.</Bubble.Content> <Bubble.Reactions> <Bubble.Reaction active>👍 4</Bubble.Reaction> <Bubble.Reaction>🚀 2</Bubble.Reaction> <Bubble.Reaction aria-label="Add reaction">☺</Bubble.Reaction> </Bubble.Reactions> </Bubble.Root></div>Installation
Section titled “Installation”npx shadcn@latest add @edmi-ui/bubblepnpm dlx shadcn@latest add @edmi-ui/bubbleyarn dlx shadcn@latest add @edmi-ui/bubblebunx shadcn@latest add @edmi-ui/bubblePick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Install the dependencies:
Terminal window npm install @base-ui/react class-variance-authorityTerminal window pnpm add @base-ui/react class-variance-authorityTerminal window yarn add @base-ui/react class-variance-authorityTerminal window bun add @base-ui/react class-variance-authorityAdd the Edmi components this one builds on:
elevation.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-vue@latest add @edmi-ui/bubblepnpm dlx shadcn-vue@latest add @edmi-ui/bubbleyarn dlx shadcn-vue@latest add @edmi-ui/bubblebunx shadcn-vue@latest add @edmi-ui/bubblePick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Install the dependencies:
Terminal window npm install reka-ui class-variance-authorityTerminal window pnpm add reka-ui class-variance-authorityTerminal window yarn add reka-ui class-variance-authorityTerminal window bun add reka-ui class-variance-authorityAdd the Edmi components this one builds on:
elevation.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/bubble.jsonpnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/bubble.jsonyarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/bubble.jsonbunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/bubble.jsonPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Add the Edmi components this one builds on:
elevation.Copy the code from Component source into your project and update the import paths to match your aliases.
import { Bubble, BubbleContent, BubbleGroup, BubbleReaction, BubbleReactions,} from "@/components/ui/bubble";
<div className="flex w-full max-w-md flex-col gap-4"> <BubbleGroup> </BubbleGroup> <BubbleGroup> <Bubble align="end"> <BubbleContent>Run it and send me the transaction.</BubbleContent> </Bubble> </BubbleGroup> <Bubble variant="secondary" className="mb-3"> <BubbleContent>The keeper just rebalanced MAG4.</BubbleContent> <BubbleReactions> <BubbleReaction active>👍 4</BubbleReaction> <BubbleReaction>🚀 2</BubbleReaction> <BubbleReaction aria-label="Add reaction"> <SmileyIcon className="size-3" /> </BubbleReaction> </BubbleReactions> </Bubble></div><script setup lang="ts">import { Bubble, BubbleContent, BubbleGroup, BubbleReaction, BubbleReactions,} from "@/components/ui/bubble";</script>
<template> <div class="flex w-full max-w-md flex-col gap-4"> <BubbleGroup> <Bubble :key="variant" :variant="variant"> <BubbleContent>{{ variant }} bubble</BubbleContent> </Bubble> </BubbleGroup> <BubbleGroup> <Bubble align="end"> <BubbleContent>Run it and send me the transaction.</BubbleContent> </Bubble> </BubbleGroup> <Bubble variant="secondary" class="mb-3"> <BubbleContent>The keeper just rebalanced MAG4.</BubbleContent> <BubbleReactions> <BubbleReaction active>👍 4</BubbleReaction> <BubbleReaction>🚀 2</BubbleReaction> <BubbleReaction aria-label="Add reaction"> <SmileIcon class="size-3" /> </BubbleReaction> </BubbleReactions> </Bubble> </div></template><script lang="ts"> import * as Bubble from "$lib/components/ui/bubble";</script>
<div class="flex w-full max-w-md flex-col gap-4"> <Bubble.Group> {#each variants as variant (variant)} <Bubble.Root {variant}> <Bubble.Content>{variant} bubble</Bubble.Content> </Bubble.Root> {/each} </Bubble.Group> <Bubble.Group> <Bubble.Root align="end"> <Bubble.Content>Run it and send me the transaction.</Bubble.Content> </Bubble.Root> </Bubble.Group> <Bubble.Root variant="secondary" class="mb-3"> <Bubble.Content>The keeper just rebalanced MAG4.</Bubble.Content> <Bubble.Reactions> <Bubble.Reaction active>👍 4</Bubble.Reaction> <Bubble.Reaction>🚀 2</Bubble.Reaction> <Bubble.Reaction aria-label="Add reaction">☺</Bubble.Reaction> </Bubble.Reactions> </Bubble.Root></div>Component source
Section titled “Component source”import { mergeProps } from "@base-ui/react/merge-props";import { useRender } from "@base-ui/react/use-render";import { cva, type VariantProps } from "class-variance-authority";import { cn } from "cn";import * as React from "react";import { type Elevation, useElevation } from "@/registry/edmi/ui/elevation";
function BubbleGroup({ className, ...props }: React.ComponentProps<"div">) { return ( <div data-slot="bubble-group" className={cn("flex min-w-0 flex-col gap-2", className)} {...props} /> );}
const bubbleVariants = cva( "group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full", { variants: { variant: { default: "*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_srgb,var(--primary)_85%,var(--background))]", secondary: "*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-accent", muted: "*:data-[slot=bubble-content]:bg-muted *:data-[slot=bubble-content]:text-muted-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-accent", tinted: "*:data-[slot=bubble-content]:border-[color-mix(in_srgb,var(--brand)_25%,var(--popover))] *:data-[slot=bubble-content]:bg-brand-soft *:data-[slot=bubble-content]:text-foreground", outline: "*:data-[slot=bubble-content]:border-input *:data-[slot=bubble-content]:bg-transparent [&>[data-slot=bubble-content]:is(button,a):hover]:bg-accent", // Ghost keeps its vertical padding so avatars line up with the first line. ghost: "*:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:px-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-accent", destructive: "*:data-[slot=bubble-content]:border-[color-mix(in_srgb,var(--destructive)_30%,var(--popover))] *:data-[slot=bubble-content]:bg-destructive-soft *:data-[slot=bubble-content]:text-destructive-text", }, }, defaultVariants: { variant: "default", }, },);
function Bubble({ variant = "default", align = "start", className, ...props}: React.ComponentProps<"div"> & VariantProps<typeof bubbleVariants> & { align?: "start" | "end"; }) { return ( <div data-slot="bubble" data-variant={variant} data-align={align} className={cn(bubbleVariants({ variant }), className)} {...props} /> );}
function BubbleContent({ className, render, ...props}: useRender.ComponentProps<"div">) { return useRender({ defaultTagName: "div", props: mergeProps<"div">( { className: cn( "w-fit max-w-full min-w-0 overflow-hidden rounded-2xl border border-transparent px-[13px] py-[9px] text-sm leading-normal wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:outline-2 [button,a]:focus-visible:outline-offset-2 [button,a]:focus-visible:outline-ring", className, ), }, props, ), render, state: { slot: "bubble-content", }, });}
const bubbleReactionsVariants = cva( "absolute z-10 flex w-fit shrink-0 items-center gap-1", { variants: { side: { top: "top-0 -translate-y-3/4", bottom: "bottom-0 translate-y-3/4", }, align: { start: "left-3", end: "right-3", }, }, defaultVariants: { side: "bottom", align: "end", }, },);
// ✦ `elevation` on BubbleReactions flows to every BubbleReaction chip.const BubbleReactionsContext = React.createContext<{ raised: boolean }>({ raised: false,});
function BubbleReactions({ side = "bottom", align = "end", elevation, className, ...props}: React.ComponentProps<"div"> & { /** ✦ depth of the chips: raised +1 / floating +2 bevel every chip (`active` kept). */ elevation?: Elevation; align?: "start" | "end"; side?: "top" | "bottom";}) { const level = useElevation(elevation, "control"); return ( <BubbleReactionsContext.Provider value={{ raised: level === "raised" || level === "floating" }} > <div data-slot="bubble-reactions" data-align={align} data-side={side} className={cn(bubbleReactionsVariants({ side, align }), className)} {...props} /> </BubbleReactionsContext.Provider> );}
/** ✦ Edmi addition: a single solid floating chip (DESIGN §4.6, no ring, 1px border). */const bubbleReactionVariants = cva( "inline-flex h-[22px] items-center gap-1 rounded-full border border-border bg-popover px-[7px] text-[11.5px] text-foreground", { variants: { active: { true: "border-[color-mix(in_srgb,var(--brand)_45%,var(--popover))] bg-[color-mix(in_srgb,var(--brand)_14%,var(--popover))] text-brand-text", false: "", }, // ✦ elevation: the chip bevels (flat is the default) elevation: { flat: "", raised: "border-transparent shadow-btn-raised-neutral", }, }, defaultVariants: { active: false, elevation: "flat" }, },);
function BubbleReaction({ active = false, elevation, className, render, ...props}: useRender.ComponentProps<"button"> & { active?: boolean; /** ✦ depth of this chip; defaults to the BubbleReactions level. */ elevation?: Elevation;}) { const context = React.useContext(BubbleReactionsContext); const own = elevation && elevation !== "auto" ? elevation === "raised" || elevation === "floating" : undefined; return useRender({ defaultTagName: "button", props: mergeProps<"button">( { type: "button", "aria-pressed": !!active, className: cn( bubbleReactionVariants({ active, elevation: (own ?? context.raised) ? "raised" : "flat", }), "outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring", className, ), }, props, ), render, state: { slot: "bubble-reaction", active: !!active }, });}
export { Bubble, BubbleContent, BubbleGroup, BubbleReaction, BubbleReactions, bubbleReactionVariants, bubbleVariants,};<script lang='ts' setup>import type { PrimitiveProps } from "reka-ui"import type { HTMLAttributes } from "vue"import type { BubbleVariants } from "."import { Primitive } from "reka-ui"import { cn } from "@/registry/edmi/lib/utils"import { bubbleVariants } from "."
interface Props extends PrimitiveProps { variant?: BubbleVariants["variant"] align?: "start" | "end" class?: HTMLAttributes["class"]}
const props = withDefaults(defineProps<Props>(), { variant: "default", align: "start", as: "div",})</script>
<template> <Primitive data-slot="bubble" :data-variant="variant" :data-align="align" :as="as" :as-child="asChild" :class="cn(bubbleVariants({ variant }), props.class)" > <slot /> </Primitive></template><script lang='ts' setup>import type { PrimitiveProps } from "reka-ui"import type { HTMLAttributes } from "vue"import { Primitive } from "reka-ui"import { cn } from "@/registry/edmi/lib/utils"
interface Props extends PrimitiveProps { class?: HTMLAttributes["class"]}
const props = withDefaults(defineProps<Props>(), { as: "div",})</script>
<template> <Primitive data-slot="bubble-content" :as="as" :as-child="asChild" :class="cn( 'w-fit max-w-full min-w-0 overflow-hidden rounded-2xl border border-transparent px-[13px] py-[9px] text-sm leading-normal wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:outline-2 [button,a]:focus-visible:outline-offset-2 [button,a]:focus-visible:outline-ring', props.class, )" > <slot /> </Primitive></template><script setup lang="ts">import type { PrimitiveProps } from "reka-ui"import type { HTMLAttributes } from "vue"import { Primitive } from "reka-ui"import { cn } from "@/registry/edmi/lib/utils"
interface Props extends PrimitiveProps { class?: HTMLAttributes["class"]}
const props = withDefaults(defineProps<Props>(), { as: "div",})</script>
<template> <Primitive data-slot="bubble-group" :as="as" :as-child="asChild" :class="cn('gap-2 flex min-w-0 flex-col', props.class)" > <slot /> </Primitive></template><script lang="ts" setup>import type { PrimitiveProps } from "reka-ui"import type { HTMLAttributes } from "vue"import { Primitive } from "reka-ui"import { computed, inject } from "vue"import { cn } from "@/registry/edmi/lib/utils"import type { Elevation } from "@/registry/edmi/ui/elevation"import { bubbleReactionVariants } from "."
/** ✦ Edmi addition: a single floating reaction chip. */interface Props extends PrimitiveProps { active?: boolean /** ✦ overrides the BubbleReactions `elevation` for this chip */ elevation?: Elevation class?: HTMLAttributes["class"]}
const props = withDefaults(defineProps<Props>(), { active: false, as: "button", elevation: undefined,})
const context = inject<{ raised?: boolean } | null>("bubbleReactions", null)const raised = computed(() => props.elevation && props.elevation !== "auto" ? props.elevation === "raised" || props.elevation === "floating" : !!context?.raised,)</script>
<template> <Primitive data-slot="bubble-reaction" :data-active="active ? '' : undefined" :type="as === 'button' ? 'button' : undefined" :aria-pressed="active" :as="as" :as-child="asChild" :class="cn(bubbleReactionVariants({ active, elevation: raised ? 'raised' : 'flat' }), props.class)" > <slot /> </Primitive></template><script lang='ts' setup>import type { PrimitiveProps } from "reka-ui"import type { HTMLAttributes } from "vue"import type { BubbleReactionsVariants } from "."import { Primitive } from "reka-ui"import { computed, provide } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { type Elevation, useElevation } from "@/registry/edmi/ui/elevation"import { bubbleReactionsVariants } from "."
interface Props extends PrimitiveProps { side?: BubbleReactionsVariants["side"] align?: BubbleReactionsVariants["align"] /** ✦ depth of the chips: raised +1 / floating +2 bevel every chip (chips may override) */ elevation?: Elevation class?: HTMLAttributes["class"]}
const props = withDefaults(defineProps<Props>(), { side: "bottom", align: "end", as: "div", elevation: undefined,})
const level = useElevation(() => props.elevation, "control")const raised = computed(() => level.value === "raised" || level.value === "floating")
provide("bubbleReactions", { get raised() { return raised.value },})</script>
<template> <Primitive data-slot="bubble-reactions" :data-side="side" :data-align="align" :as="as" :as-child="asChild" :class="cn(bubbleReactionsVariants({ side, align }), props.class)" > <slot /> </Primitive></template>import type { VariantProps } from "class-variance-authority";import { cva } from "class-variance-authority";
export { default as Bubble } from "./Bubble.vue";export { default as BubbleContent } from "./BubbleContent.vue";export { default as BubbleGroup } from "./BubbleGroup.vue";export { default as BubbleReaction } from "./BubbleReaction.vue";export { default as BubbleReactions } from "./BubbleReactions.vue";
export const bubbleVariants = cva( "group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full", { variants: { variant: { default: "*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_srgb,var(--primary)_85%,var(--background))]", secondary: "*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-accent", muted: "*:data-[slot=bubble-content]:bg-muted *:data-[slot=bubble-content]:text-muted-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-accent", tinted: "*:data-[slot=bubble-content]:border-[color-mix(in_srgb,var(--brand)_25%,var(--popover))] *:data-[slot=bubble-content]:bg-brand-soft *:data-[slot=bubble-content]:text-foreground", outline: "*:data-[slot=bubble-content]:border-input *:data-[slot=bubble-content]:bg-transparent [&>[data-slot=bubble-content]:is(button,a):hover]:bg-accent", // Ghost keeps its vertical padding so avatars line up with the first line. ghost: "*:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:px-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-accent", destructive: "*:data-[slot=bubble-content]:border-[color-mix(in_srgb,var(--destructive)_30%,var(--popover))] *:data-[slot=bubble-content]:bg-destructive-soft *:data-[slot=bubble-content]:text-destructive-text", }, }, defaultVariants: { variant: "default", }, },);export type BubbleVariants = VariantProps<typeof bubbleVariants>;
export const bubbleReactionsVariants = cva( "absolute z-10 flex w-fit shrink-0 items-center gap-1", { variants: { side: { top: "top-0 -translate-y-3/4", bottom: "bottom-0 translate-y-3/4", }, align: { start: "left-3", end: "right-3", }, }, defaultVariants: { side: "bottom", align: "end", }, },);export type BubbleReactionsVariants = VariantProps< typeof bubbleReactionsVariants>;
/** ✦ Edmi addition: a single solid floating chip (DESIGN §4.6, no ring, 1px border). */export const bubbleReactionVariants = cva( "inline-flex h-[22px] items-center gap-1 rounded-full border border-border bg-popover px-[7px] text-[11.5px] text-foreground outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring", { variants: { active: { true: "border-[color-mix(in_srgb,var(--brand)_45%,var(--popover))] bg-[color-mix(in_srgb,var(--brand)_14%,var(--popover))] text-brand-text", false: "", }, // ✦ elevation: the chip bevels (flat is the default) elevation: { flat: "", raised: "border-transparent shadow-btn-raised-neutral", }, }, defaultVariants: { active: false, elevation: "flat" }, },);export type BubbleReactionVariants = VariantProps< typeof bubbleReactionVariants>;<script lang="ts"> import { cn, type WithElementRef } from "$lib/utils.js"; import type { HTMLAnchorAttributes, HTMLAttributes, HTMLButtonAttributes } from "svelte/elements";
// Renders a `<div>`; pass `href` for an `<a>` or `as="button"` for a `<button>`. let { ref = $bindable(null), class: className, as = "div", href = undefined, children, ...restProps }: WithElementRef<HTMLAttributes<HTMLElement>> & WithElementRef<HTMLAnchorAttributes> & WithElementRef<HTMLButtonAttributes> & { as?: "div" | "button" | "a" } = $props();
const classes = $derived( cn( "w-fit max-w-full min-w-0 overflow-hidden rounded-2xl border border-transparent px-[13px] py-[9px] text-sm leading-normal wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:outline-2 [button,a]:focus-visible:outline-offset-2 [button,a]:focus-visible:outline-ring", className ) );</script>
{#if href || as === "a"} <a bind:this={ref} data-slot="bubble-content" class={classes} {href} {...restProps}> {@render children?.()} </a>{:else if as === "button"} <button bind:this={ref} data-slot="bubble-content" class={classes} type="button" {...restProps}> {@render children?.()} </button>{:else} <div bind:this={ref} data-slot="bubble-content" class={classes} {...restProps}> {@render children?.()} </div>{/if}<script lang="ts"> import { cn, type WithElementRef } from "$lib/utils.js"; import type { HTMLAttributes } from "svelte/elements";
let { ref = $bindable(null), class: className, children, ...restProps }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();</script>
<div bind:this={ref} data-slot="bubble-group" class={cn( "flex min-w-0 flex-col gap-2", className )} {...restProps}> {@render children?.()}</div><script lang="ts" module> import { type VariantProps, tv } from "tailwind-variants";
/** ✦ Edmi addition: a single solid floating chip (DESIGN §4.6, no ring, 1px border). */ export const bubbleReactionVariants = tv({ base: "inline-flex h-[22px] items-center gap-1 rounded-full border border-border bg-popover px-[7px] text-[11.5px] text-foreground outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring", variants: { active: { true: "border-[color-mix(in_srgb,var(--brand)_45%,var(--popover))] bg-[color-mix(in_srgb,var(--brand)_14%,var(--popover))] text-brand-text", false: "", }, // ✦ elevation: the chip bevels (flat is the default) elevation: { flat: "", raised: "border-transparent shadow-btn-raised-neutral" }, }, defaultVariants: { active: false, elevation: "flat" }, });
export type BubbleReactionVariants = VariantProps<typeof bubbleReactionVariants>;</script>
<script lang="ts"> import { cn, type WithElementRef } from "$lib/utils.js"; import type { HTMLButtonAttributes } from "svelte/elements"; import type { Elevation } from "$lib/registry/ui/elevation/index.js"; import { getBubbleReactionsCtx } from "./bubble-reactions.svelte";
let { ref = $bindable(null), class: className, active = false, elevation = "auto", children, ...restProps }: WithElementRef<HTMLButtonAttributes> & { active?: boolean; /** ✦ overrides the BubbleReactions `elevation` for this chip. */ elevation?: Elevation; } = $props();
const ctx = getBubbleReactionsCtx(); const isRaised = $derived( elevation !== "auto" ? elevation === "raised" || elevation === "floating" : (ctx?.raised ?? false) );</script>
<button bind:this={ref} data-slot="bubble-reaction" data-active={active ? "" : undefined} type="button" aria-pressed={active} class={cn(bubbleReactionVariants({ active, elevation: isRaised ? "raised" : "flat" }), className)} {...restProps}> {@render children?.()}</button><script lang="ts" module> import { getContext, setContext } from "svelte"; import { type VariantProps, tv } from "tailwind-variants";
export const bubbleReactionsVariants = tv({ base: "absolute z-10 flex w-fit shrink-0 items-center gap-1", variants: { side: { top: "top-0 -translate-y-3/4", bottom: "bottom-0 translate-y-3/4", }, align: { start: "left-3", end: "right-3", }, }, defaultVariants: { side: "bottom", align: "end", }, });
export type BubbleReactionsSide = VariantProps<typeof bubbleReactionsVariants>["side"]; export type BubbleReactionsAlign = VariantProps<typeof bubbleReactionsVariants>["align"];
export function setBubbleReactionsCtx(ctx: { raised: boolean }) { setContext("bubbleReactions", ctx); }
export function getBubbleReactionsCtx() { return getContext<{ raised: boolean } | undefined>("bubbleReactions"); }</script>
<script lang="ts"> import { cn, type WithElementRef } from "$lib/utils.js"; import type { HTMLAttributes } from "svelte/elements"; import { type Elevation, useElevation } from "$lib/registry/ui/elevation/index.js";
let { ref = $bindable(null), class: className, side = "bottom", align = "end", elevation = "auto", children, ...restProps }: WithElementRef<HTMLAttributes<HTMLDivElement>> & { side?: BubbleReactionsSide; align?: BubbleReactionsAlign; /** ✦ depth of the chips: raised +1 / floating +2 bevel every `BubbleReaction` chip. */ elevation?: Elevation; } = $props();
const level = useElevation(() => elevation, "control"); const raised = $derived(level.current === "raised" || level.current === "floating");
setBubbleReactionsCtx({ get raised() { return raised; }, });</script>
<div bind:this={ref} data-slot="bubble-reactions" data-align={align} data-side={side} class={cn(bubbleReactionsVariants({ side, align }), className)} {...restProps}> {@render children?.()}</div><script lang="ts" module> import { type VariantProps, tv } from "tailwind-variants";
export const bubbleVariants = tv({ base: "group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full", variants: { variant: { default: "*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_srgb,var(--primary)_85%,var(--background))]", secondary: "*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-accent", muted: "*:data-[slot=bubble-content]:bg-muted *:data-[slot=bubble-content]:text-muted-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-accent", tinted: "*:data-[slot=bubble-content]:border-[color-mix(in_srgb,var(--brand)_25%,var(--popover))] *:data-[slot=bubble-content]:bg-brand-soft *:data-[slot=bubble-content]:text-foreground", outline: "*:data-[slot=bubble-content]:border-input *:data-[slot=bubble-content]:bg-transparent [&>[data-slot=bubble-content]:is(button,a):hover]:bg-accent", // Ghost keeps its vertical padding so avatars line up with the first line. ghost: "*:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:px-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-accent", destructive: "*:data-[slot=bubble-content]:border-[color-mix(in_srgb,var(--destructive)_30%,var(--popover))] *:data-[slot=bubble-content]:bg-destructive-soft *:data-[slot=bubble-content]:text-destructive-text", }, }, defaultVariants: { variant: "default", }, });
export type BubbleVariant = VariantProps<typeof bubbleVariants>["variant"];</script>
<script lang="ts"> import { cn, type WithElementRef } from "$lib/utils.js"; import type { HTMLAttributes } from "svelte/elements";
let { ref = $bindable(null), class: className, variant = "default", align = "start", children, ...restProps }: WithElementRef<HTMLAttributes<HTMLDivElement>> & { variant?: BubbleVariant; align?: "start" | "end"; } = $props();</script>
<div bind:this={ref} data-slot="bubble" data-variant={variant} data-align={align} class={cn(bubbleVariants({ variant }), className)} {...restProps}> {@render children?.()}</div>import Root, { type BubbleVariant, bubbleVariants } from "./bubble.svelte";import Content from "./bubble-content.svelte";import Group from "./bubble-group.svelte";import Reaction, { bubbleReactionVariants } from "./bubble-reaction.svelte";import Reactions, { bubbleReactionsVariants } from "./bubble-reactions.svelte";
export { type BubbleVariant, bubbleReactionsVariants, bubbleReactionVariants, bubbleVariants, Content, Content as BubbleContent, Group, Group as BubbleGroup, Reaction, Reaction as BubbleReaction, Reactions, Reactions as BubbleReactions, Root, // Root as Bubble,};Elevation ✦
Section titled “Elevation ✦”See the Elevation guide for the levels, roles and layered mode.
Flat by default. elevation on BubbleReactions (raised +1 or floating +2) bevels every BubbleReaction chip; the active tint is kept. A chip can override it with its own elevation.
Flat (0)
Raised (+1)
Flat (0)
Raised (+1)
Flat (0)
Raised (+1)
import { Bubble, BubbleContent, BubbleGroup, BubbleReaction, BubbleReactions,} from "@/components/ui/bubble";import type { Elevation } from "@/components/ui/elevation";import { IconPlaceholder } from "@/edmi/icon-placeholder";
const variants = [ "default", "secondary", "muted", "tinted", "outline", "ghost", "destructive",] as const;
function Sample({ elevation }: { elevation: Elevation }) { return ( <div className="flex w-full max-w-md flex-col gap-4"> <BubbleGroup> {variants.map((variant) => ( <Bubble key={variant} variant={variant}> <BubbleContent>{variant} bubble</BubbleContent> </Bubble> ))} </BubbleGroup> <BubbleGroup> <Bubble align="end"> <BubbleContent>Run it and send me the transaction.</BubbleContent> </Bubble> </BubbleGroup> <Bubble variant="secondary" className="mb-3"> <BubbleContent>The keeper just rebalanced MAG4.</BubbleContent> <BubbleReactions elevation={elevation}> <BubbleReaction active>👍 4</BubbleReaction> <BubbleReaction>🚀 2</BubbleReaction> <BubbleReaction aria-label="Add reaction"> <IconPlaceholder lucide="SmileIcon" tabler="IconMoodSmile" hugeicons="SmileIcon" phosphor="SmileyIcon" remixicon="RiEmotionLine" className="size-3" /> </BubbleReaction> </BubbleReactions> </Bubble> </div> );}
const levels = [ { value: "flat", label: "Flat (0)" }, { value: "raised", label: "Raised (+1)" },] as const;
export default function Demo() { return ( <div className="flex flex-col gap-6"> {levels.map(({ value, label }) => ( <div key={value} className="flex flex-col gap-2"> <p className="text-xs font-medium text-muted-foreground">{label}</p> <Sample elevation={value} /> </div> ))} </div> );}<script setup lang="ts">import { Bubble, BubbleContent, BubbleReaction, BubbleReactions } from "@/components/ui/bubble";
const levels = [ { value: "flat", label: "Flat (0)" } as const, { value: "raised", label: "Raised (+1)" } as const,];</script>
<template> <div class="flex flex-col gap-6"> <div v-for="l in levels" :key="l.value" class="flex flex-col gap-2"> <p class="text-xs font-medium text-muted-foreground">{{ l.label }}</p> <div class="flex w-full max-w-md flex-col gap-8"> <Bubble variant="secondary"> <BubbleContent>Flat reactions.</BubbleContent> <BubbleReactions> <BubbleReaction active>👍 4</BubbleReaction> <BubbleReaction>🚀 2</BubbleReaction> </BubbleReactions> </Bubble> <Bubble variant="secondary"> <BubbleContent>Raised reactions.</BubbleContent> <BubbleReactions :elevation="l.value"> <BubbleReaction active>👍 4</BubbleReaction> <BubbleReaction>🚀 2</BubbleReaction> </BubbleReactions> </Bubble> </div> </div> </div></template><script lang="ts"> import * as Bubble from "$lib/components/ui/bubble";
const levels = [ { value: "flat", label: "Flat (0)" } as const, { value: "raised", label: "Raised (+1)" } as const, ];</script>
<div class="flex flex-col gap-6"> {#each levels as l (l.value)} <div class="flex flex-col gap-2"> <p class="text-xs font-medium text-muted-foreground">{l.label}</p><div class="flex w-full max-w-md flex-col gap-4"> <Bubble.Root variant="secondary" class="mb-3"> <Bubble.Content>The keeper just rebalanced MAG4.</Bubble.Content> <Bubble.Reactions elevation={l.value}> <Bubble.Reaction active>👍 4</Bubble.Reaction> <Bubble.Reaction>🚀 2</Bubble.Reaction> <Bubble.Reaction aria-label="Add reaction">☺</Bubble.Reaction> </Bubble.Reactions> </Bubble.Root></div> </div> {/each}</div>| Part | Prop | Type | Default |
|---|---|---|---|
| Bubble | variant | default | secondary | muted | tinted | outline | ghost | destructive |
default |
| Bubble | align | start | end |
start |
| BubbleContent | render | ReactElement (button or link bubbles) |
none |
| BubbleReactions | side | top | bottom |
bottom |
| BubbleReactions | align | start | end |
end |
| BubbleGroup | stacks bubbles | div props |
none |
| BubbleReactions | elevation | "auto" | "sunken" | "flat" | "raised" | "floating" |
"auto" |
✦ Edmi additions
Section titled “✦ Edmi additions”BubbleReaction(active): a solid floating chip with a 1px border and no ring; the active chip is brand-tinted.tintedanddestructiveuse the soft brand and destructive tokens;ghostkeeps its vertical padding so avatars line up.
Vue / Svelte differences
Section titled “Vue / Svelte differences”- Svelte:
BubbleContenthas norenderprop. Passhrefto render an<a>, oras="button"for a<button>.