diff --git a/docs/app/components/shared/components_list.rb b/docs/app/components/shared/components_list.rb
index 60be2224..eae5c84d 100644
--- a/docs/app/components/shared/components_list.rb
+++ b/docs/app/components/shared/components_list.rb
@@ -12,6 +12,7 @@ def components
{name: "Avatar", path: docs_avatar_path},
{name: "Badge", path: docs_badge_path},
{name: "Breadcrumb", path: docs_breadcrumb_path},
+ {name: "Bubble", path: docs_bubble_path},
{name: "Button", path: docs_button_path},
{name: "Calendar", path: docs_calendar_path},
{name: "Card", path: docs_card_path},
@@ -34,6 +35,7 @@ def components
{name: "Input", path: docs_input_path},
{name: "Link", path: docs_link_path},
{name: "Masked Input", path: masked_input_path},
+ {name: "Message", path: docs_message_path},
{name: "Pagination", path: docs_pagination_path},
{name: "Popover", path: docs_popover_path},
{name: "Progress", path: docs_progress_path},
diff --git a/docs/app/controllers/docs_controller.rb b/docs/app/controllers/docs_controller.rb
index ce1dc881..d5e8473d 100644
--- a/docs/app/controllers/docs_controller.rb
+++ b/docs/app/controllers/docs_controller.rb
@@ -74,6 +74,10 @@ def breadcrumb
render Views::Docs::Breadcrumb.new
end
+ def bubble
+ render Views::Docs::Bubble.new
+ end
+
def button
render Views::Docs::Button.new
end
@@ -162,6 +166,10 @@ def masked_input
render Views::Docs::MaskedInput.new
end
+ def message
+ render Views::Docs::Message.new
+ end
+
def pagination
render Views::Docs::Pagination.new
end
diff --git a/docs/app/lib/site_files.rb b/docs/app/lib/site_files.rb
index f9c6d3f8..b0984020 100644
--- a/docs/app/lib/site_files.rb
+++ b/docs/app/lib/site_files.rb
@@ -86,6 +86,7 @@ class SiteFiles
{title: "Avatar", path: "/docs/avatar", description: "Image and fallback primitives for representing a user."},
{title: "Badge", path: "/docs/badge", description: "Small status or label element."},
{title: "Breadcrumb", path: "/docs/breadcrumb", description: "Navigation trail showing the current location in a hierarchy."},
+ {title: "Bubble", path: "/docs/bubble", description: "Chat bubble surface with variants, alignment, grouping, and reactions."},
{title: "Button", path: "/docs/button", description: "Button component and button-like variants."},
{title: "Calendar", path: "/docs/calendar", description: "Date field component for entering and editing dates."},
{title: "Card", path: "/docs/card", description: "Content container with header, content, and footer primitives."},
@@ -107,6 +108,7 @@ class SiteFiles
{title: "Input", path: "/docs/input", description: "Styled input field primitive."},
{title: "Link", path: "/docs/link", description: "Link component with button-like and underline variants."},
{title: "Masked Input", path: "/docs/masked_input", description: "Form input with an applied mask."},
+ {title: "Message", path: "/docs/message", description: "Chat message layout pairing an avatar with bubbles, headers, and footers."},
{title: "Pagination", path: "/docs/pagination", description: "Page navigation with next and previous links."},
{title: "Popover", path: "/docs/popover", description: "Triggered rich content panel."},
{title: "Progress", path: "/docs/progress", description: "Progress bar for task completion state."},
diff --git a/docs/app/views/docs/bubble.rb b/docs/app/views/docs/bubble.rb
new file mode 100644
index 00000000..d447ebc7
--- /dev/null
+++ b/docs/app/views/docs/bubble.rb
@@ -0,0 +1,167 @@
+# frozen_string_literal: true
+
+class Views::Docs::Bubble < Views::Base
+ def view_template
+ component = "Bubble"
+
+ div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
+ render Docs::Header.new(title: "Bubble", description: "A chat bubble surface for displaying conversational content, with variants, alignment, grouping, and reactions.")
+
+ Heading(level: 2) { "Usage" }
+
+ render Docs::VisualCodeExample.new(title: "Default", context: self) do
+ <<~RUBY
+ Bubble(align: :end) do
+ BubbleContent { "Hey there! what's up?" }
+ end
+ RUBY
+ end
+
+ render Docs::VisualCodeExample.new(title: "Conversation", context: self) do
+ <<~RUBY
+ div(class: "flex flex-col gap-8") do
+ Bubble(align: :end) do
+ BubbleContent { "Hey there! what's up?" }
+ end
+ BubbleGroup do
+ Bubble(variant: :muted) do
+ BubbleContent { "Hey! Want to see chat bubbles?" }
+ end
+ Bubble(variant: :muted) do
+ BubbleContent { "I can group messages, switch sides, and keep the whole thread easy to scan." }
+ BubbleReactions(role: "img", aria_label: "Reaction: thumbs up") do
+ span { "👍" }
+ end
+ end
+ end
+ Bubble(align: :end) do
+ BubbleContent { "Sure. Hit me with your best demo." }
+ end
+ end
+ RUBY
+ end
+
+ Heading(level: 2) { "Variants" }
+
+ render Docs::VisualCodeExample.new(title: "Variants", context: self) do
+ <<~RUBY
+ div(class: "flex flex-col gap-4 w-full") do
+ Bubble(variant: :default) { BubbleContent { "Default" } }
+ Bubble(variant: :secondary) { BubbleContent { "Secondary" } }
+ Bubble(variant: :muted) { BubbleContent { "Muted" } }
+ Bubble(variant: :tinted) { BubbleContent { "Tinted" } }
+ Bubble(variant: :outline) { BubbleContent { "Outline" } }
+ Bubble(variant: :ghost) { BubbleContent { "Ghost — unframed, full width for assistant text or markdown." } }
+ Bubble(variant: :destructive) { BubbleContent { "Destructive — something went wrong." } }
+ end
+ RUBY
+ end
+
+ Heading(level: 2) { "Alignment" }
+
+ render Docs::VisualCodeExample.new(title: "Start and end", context: self) do
+ <<~RUBY
+ div(class: "flex flex-col gap-4 w-full") do
+ Bubble(align: :start, variant: :muted) do
+ BubbleContent { "Aligned to the start (receiver)." }
+ end
+ Bubble(align: :end) do
+ BubbleContent { "Aligned to the end (sender)." }
+ end
+ end
+ RUBY
+ end
+
+ Heading(level: 2) { "Reactions" }
+
+ render Docs::VisualCodeExample.new(title: "Reactions", context: self) do
+ <<~RUBY
+ div(class: "flex flex-col gap-10 w-full py-6") do
+ Bubble(variant: :muted) do
+ BubbleContent { "Reactions anchor to the bubble edge." }
+ BubbleReactions(role: "img", aria_label: "Reactions: thumbs up, fire, eyes, and 2 more") do
+ span { "👍" }
+ span { "🔥" }
+ span { "👀" }
+ span { "+2" }
+ end
+ end
+ Bubble(align: :end) do
+ BubbleContent { "Place them on top and to the start too." }
+ BubbleReactions(side: :top, align: :start, role: "img", aria_label: "Reaction: heart") do
+ span { "❤️" }
+ end
+ end
+ end
+ RUBY
+ end
+
+ Heading(level: 2) { "Group" }
+
+ render Docs::VisualCodeExample.new(title: "Bubble group", context: self) do
+ <<~RUBY
+ BubbleGroup do
+ Bubble(variant: :muted) { BubbleContent { "First message in the group." } }
+ Bubble(variant: :muted) { BubbleContent { "Second one, tighter spacing." } }
+ Bubble(variant: :muted) { BubbleContent { "Third, all stacked together." } }
+ end
+ RUBY
+ end
+
+ Heading(level: 2) { "Link or button bubble" }
+
+ render Docs::VisualCodeExample.new(title: "Interactive content", context: self) do
+ <<~RUBY
+ div(class: "flex flex-col gap-4 w-full") do
+ Bubble(align: :end) do
+ BubbleContent(as: :a, href: "#") { "Tap to open the link →" }
+ end
+ Bubble(variant: :outline) do
+ BubbleContent(as: :button, type: "button") { "Retry sending" }
+ end
+ end
+ RUBY
+ end
+
+ Heading(level: 2) { "With Tooltip" }
+
+ render Docs::VisualCodeExample.new(title: "Reveal metadata on hover", context: self) do
+ <<~RUBY
+ Tooltip do
+ TooltipTrigger(class: "w-fit") do
+ Bubble(variant: :muted, class: "max-w-none") do
+ BubbleContent { "Read 9:41 AM" }
+ end
+ end
+ TooltipContent do
+ Text { "Delivered and read" }
+ end
+ end
+ RUBY
+ end
+
+ Heading(level: 2) { "With Popover" }
+
+ render Docs::VisualCodeExample.new(title: "Surface details on demand", context: self) do
+ <<~RUBY
+ Popover do
+ PopoverTrigger do
+ Bubble(variant: :destructive, class: "max-w-none") do
+ BubbleContent { "Message failed to send" }
+ end
+ end
+ PopoverContent(class: "w-64") do
+ Text(weight: :semibold) { "Delivery error" }
+ Text(size: :sm, class: "text-muted-foreground") { "The recipient's inbox is full. Try again later." }
+ end
+ end
+ RUBY
+ end
+
+ render Components::ComponentSetup::Tabs.new(component_name: component)
+
+ # components
+ render Docs::ComponentsTable.new(component_files(component))
+ end
+ end
+end
diff --git a/docs/app/views/docs/message.rb b/docs/app/views/docs/message.rb
new file mode 100644
index 00000000..7488396b
--- /dev/null
+++ b/docs/app/views/docs/message.rb
@@ -0,0 +1,173 @@
+# frozen_string_literal: true
+
+class Views::Docs::Message < Views::Base
+ def view_template
+ component = "Message"
+
+ div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
+ render Docs::Header.new(title: "Message", description: "A chat message layout that pairs an avatar with bubbles, headers, and footers. Built on top of Avatar and Bubble.")
+
+ Heading(level: 2) { "Usage" }
+
+ render Docs::VisualCodeExample.new(title: "Conversation", context: self) do
+ <<~RUBY
+ div(class: "flex flex-col gap-6") do
+ Message(align: :end) do
+ MessageAvatar do
+ Avatar(size: :sm) do
+ AvatarImage(src: "https://github.com/joeldrapper.png", alt: "@me")
+ AvatarFallback { "ME" }
+ end
+ end
+ MessageContent do
+ Bubble do
+ BubbleContent { "Deploying to prod real quick." }
+ end
+ end
+ end
+
+ Message do
+ MessageAvatar do
+ Avatar(size: :sm) do
+ AvatarImage(src: "https://github.com/shadcn.png", alt: "@rabbit")
+ AvatarFallback { "R" }
+ end
+ end
+ MessageContent do
+ Bubble(variant: :muted) do
+ BubbleContent { "It's 4:55 PM. On a Friday." }
+ end
+ end
+ end
+
+ Message(align: :end) do
+ MessageAvatar do
+ Avatar(size: :sm) do
+ AvatarImage(src: "https://github.com/joeldrapper.png", alt: "@me")
+ AvatarFallback { "ME" }
+ end
+ end
+ MessageContent do
+ Bubble do
+ BubbleContent { "It's a one-line change." }
+ end
+ MessageFooter { "Delivered" }
+ end
+ end
+
+ Message do
+ MessageAvatar do
+ Avatar(size: :sm) do
+ AvatarImage(src: "https://github.com/shadcn.png", alt: "@rabbit")
+ AvatarFallback { "R" }
+ end
+ end
+ MessageContent do
+ BubbleGroup do
+ Bubble(variant: :muted) do
+ BubbleContent { "It's always a one-line change 😭." }
+ end
+ Bubble(variant: :muted) do
+ BubbleContent { "Alright, let me take a look." }
+ BubbleReactions(role: "img", aria_label: "Reaction: thumbs up") do
+ span { "👍" }
+ end
+ end
+ end
+ end
+ end
+ end
+ RUBY
+ end
+
+ Heading(level: 2) { "With header" }
+
+ render Docs::VisualCodeExample.new(title: "Header and footer", context: self) do
+ <<~RUBY
+ Message do
+ MessageAvatar do
+ Avatar(size: :sm) do
+ AvatarImage(src: "https://github.com/shadcn.png", alt: "@oliver")
+ AvatarFallback { "OL" }
+ end
+ end
+ MessageContent do
+ MessageHeader { "Oliver" }
+ Bubble(variant: :muted) do
+ BubbleContent { "Pushed the fix, can you review?" }
+ end
+ MessageFooter { "9:41 AM" }
+ end
+ end
+ RUBY
+ end
+
+ Heading(level: 2) { "Alignment" }
+
+ render Docs::VisualCodeExample.new(title: "Sender and receiver", context: self) do
+ <<~RUBY
+ div(class: "flex flex-col gap-6") do
+ Message do
+ MessageAvatar do
+ Avatar(size: :sm) do
+ AvatarImage(src: "https://github.com/shadcn.png", alt: "@rabbit")
+ AvatarFallback { "R" }
+ end
+ end
+ MessageContent do
+ Bubble(variant: :muted) { BubbleContent { "Aligned to the start." } }
+ end
+ end
+ Message(align: :end) do
+ MessageAvatar do
+ Avatar(size: :sm) do
+ AvatarImage(src: "https://github.com/joeldrapper.png", alt: "@me")
+ AvatarFallback { "ME" }
+ end
+ end
+ MessageContent do
+ Bubble { BubbleContent { "Aligned to the end." } }
+ end
+ end
+ end
+ RUBY
+ end
+
+ Heading(level: 2) { "Group" }
+
+ render Docs::VisualCodeExample.new(title: "Message group", context: self) do
+ <<~RUBY
+ MessageGroup do
+ Message do
+ MessageAvatar do
+ Avatar(size: :sm) do
+ AvatarImage(src: "https://github.com/shadcn.png", alt: "@rabbit")
+ AvatarFallback { "R" }
+ end
+ end
+ MessageContent do
+ Bubble(variant: :muted) { BubbleContent { "First message." } }
+ end
+ end
+ Message do
+ MessageAvatar do
+ Avatar(size: :sm) do
+ AvatarImage(src: "https://github.com/shadcn.png", alt: "@rabbit")
+ AvatarFallback { "R" }
+ end
+ end
+ MessageContent do
+ Bubble(variant: :muted) { BubbleContent { "Second, tighter spacing." } }
+ end
+ end
+ end
+ RUBY
+ end
+
+ render Components::ComponentSetup::Tabs.new(component_name: component)
+
+ # components
+ render Docs::ComponentsTable.new(component_files(component))
+ end
+ end
+end
diff --git a/docs/config/routes.rb b/docs/config/routes.rb
index 59107400..59702c7f 100644
--- a/docs/config/routes.rb
+++ b/docs/config/routes.rb
@@ -30,6 +30,7 @@
get "avatar", to: "docs#avatar", as: :docs_avatar
get "badge", to: "docs#badge", as: :docs_badge
get "breadcrumb", to: "docs#breadcrumb", as: :docs_breadcrumb
+ get "bubble", to: "docs#bubble", as: :docs_bubble
get "button", to: "docs#button", as: :docs_button
get "card", to: "docs#card", as: :docs_card
get "carousel", to: "docs#carousel", as: :docs_carousel
@@ -51,6 +52,7 @@
get "input", to: "docs#input", as: :docs_input
get "link", to: "docs#link", as: :docs_link
get "masked_input", to: "docs#masked_input", as: :masked_input
+ get "message", to: "docs#message", as: :docs_message
get "pagination", to: "docs#pagination", as: :docs_pagination
get "popover", to: "docs#popover", as: :docs_popover
get "progress", to: "docs#progress", as: :docs_progress
diff --git a/docs/public/llms-full.txt b/docs/public/llms-full.txt
index 8e0feff8..3bd2e280 100644
--- a/docs/public/llms-full.txt
+++ b/docs/public/llms-full.txt
@@ -118,6 +118,11 @@ This file expands the curated /llms.txt map into a compact reference that can be
- URL: https://rubyui.com/docs/breadcrumb
- Summary: Navigation trail showing the current location in a hierarchy.
+### Bubble
+
+- URL: https://rubyui.com/docs/bubble
+- Summary: Chat bubble surface with variants, alignment, grouping, and reactions.
+
### Button
- URL: https://rubyui.com/docs/button
@@ -223,6 +228,11 @@ This file expands the curated /llms.txt map into a compact reference that can be
- URL: https://rubyui.com/docs/masked_input
- Summary: Form input with an applied mask.
+### Message
+
+- URL: https://rubyui.com/docs/message
+- Summary: Chat message layout pairing an avatar with bubbles, headers, and footers.
+
### Pagination
- URL: https://rubyui.com/docs/pagination
diff --git a/docs/public/llms.txt b/docs/public/llms.txt
index a02be577..722ad3ac 100644
--- a/docs/public/llms.txt
+++ b/docs/public/llms.txt
@@ -28,6 +28,7 @@ Use the core docs first for installation, theming, dark mode, and customization
- [Avatar](https://rubyui.com/docs/avatar): Image and fallback primitives for representing a user.
- [Badge](https://rubyui.com/docs/badge): Small status or label element.
- [Breadcrumb](https://rubyui.com/docs/breadcrumb): Navigation trail showing the current location in a hierarchy.
+- [Bubble](https://rubyui.com/docs/bubble): Chat bubble surface with variants, alignment, grouping, and reactions.
- [Button](https://rubyui.com/docs/button): Button component and button-like variants.
- [Calendar](https://rubyui.com/docs/calendar): Date field component for entering and editing dates.
- [Card](https://rubyui.com/docs/card): Content container with header, content, and footer primitives.
@@ -49,6 +50,7 @@ Use the core docs first for installation, theming, dark mode, and customization
- [Input](https://rubyui.com/docs/input): Styled input field primitive.
- [Link](https://rubyui.com/docs/link): Link component with button-like and underline variants.
- [Masked Input](https://rubyui.com/docs/masked_input): Form input with an applied mask.
+- [Message](https://rubyui.com/docs/message): Chat message layout pairing an avatar with bubbles, headers, and footers.
- [Pagination](https://rubyui.com/docs/pagination): Page navigation with next and previous links.
- [Popover](https://rubyui.com/docs/popover): Triggered rich content panel.
- [Progress](https://rubyui.com/docs/progress): Progress bar for task completion state.
diff --git a/docs/public/sitemap.xml b/docs/public/sitemap.xml
index 2cd1f293..e3f5c942 100644
--- a/docs/public/sitemap.xml
+++ b/docs/public/sitemap.xml
@@ -95,6 +95,11 @@
monthly
0.7
+
+ https://rubyui.com/docs/bubble
+ monthly
+ 0.7
+
https://rubyui.com/docs/button
monthly
@@ -200,6 +205,11 @@
monthly
0.7
+
+ https://rubyui.com/docs/message
+ monthly
+ 0.7
+
https://rubyui.com/docs/pagination
monthly
diff --git a/gem/lib/generators/ruby_ui/dependencies.yml b/gem/lib/generators/ruby_ui/dependencies.yml
index 3d774b00..64e71c5d 100644
--- a/gem/lib/generators/ruby_ui/dependencies.yml
+++ b/gem/lib/generators/ruby_ui/dependencies.yml
@@ -73,6 +73,11 @@ masked_input:
js_packages:
- "maska"
+message:
+ components:
+ - "Avatar"
+ - "Bubble"
+
pagination:
components:
- "Button"
diff --git a/gem/lib/ruby_ui/bubble/bubble.rb b/gem/lib/ruby_ui/bubble/bubble.rb
new file mode 100644
index 00000000..85ce6c20
--- /dev/null
+++ b/gem/lib/ruby_ui/bubble/bubble.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module RubyUI
+ class Bubble < Base
+ VARIANTS = {
+ default: "*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80",
+ secondary: "*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]",
+ muted: "*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]",
+ tinted: "*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]",
+ outline: "*:data-[slot=bubble-content]:bg-background *:data-[slot=bubble-content]:border-border [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30",
+ ghost: "*:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50 border-none",
+ destructive: "*:data-[slot=bubble-content]:bg-destructive/10 dark:*:data-[slot=bubble-content]:bg-destructive/20 *:data-[slot=bubble-content]:text-destructive [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30"
+ }
+
+ def initialize(variant: :default, align: :start, **attrs)
+ @variant = variant
+ @align = align
+ super(**attrs)
+ end
+
+ def view_template(&)
+ div(**attrs, &)
+ end
+
+ private
+
+ def default_attrs
+ {
+ data: {slot: "bubble", variant: @variant, align: @align},
+ class: [
+ "group/bubble relative flex w-fit min-w-0 flex-col gap-1 max-w-[80%] data-[align=end]:self-end data-[variant=ghost]:max-w-full",
+ VARIANTS[@variant]
+ ]
+ }
+ end
+ end
+end
diff --git a/gem/lib/ruby_ui/bubble/bubble_content.rb b/gem/lib/ruby_ui/bubble/bubble_content.rb
new file mode 100644
index 00000000..b8f0bcd1
--- /dev/null
+++ b/gem/lib/ruby_ui/bubble/bubble_content.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module RubyUI
+ class BubbleContent < Base
+ def initialize(as: :div, **attrs)
+ @as = as
+ super(**attrs)
+ end
+
+ def view_template(&)
+ send(@as, **attrs, &)
+ end
+
+ private
+
+ def default_attrs
+ {
+ data: {slot: "bubble-content"},
+ class: "w-fit max-w-full min-w-0 overflow-hidden wrap-break-word rounded-3xl border border-transparent px-3 py-2.5 text-sm leading-relaxed group-data-[align=end]/bubble:self-end [&:is(button,a)]:text-left [&:is(button,a)]:outline-none [&:is(button,a)]:transition-colors [&:is(button,a):focus-visible]:border-ring [&:is(button,a):focus-visible]:ring-3 [&:is(button,a):focus-visible]:ring-ring/30"
+ }
+ end
+ end
+end
diff --git a/gem/lib/ruby_ui/bubble/bubble_docs.rb b/gem/lib/ruby_ui/bubble/bubble_docs.rb
new file mode 100644
index 00000000..d447ebc7
--- /dev/null
+++ b/gem/lib/ruby_ui/bubble/bubble_docs.rb
@@ -0,0 +1,167 @@
+# frozen_string_literal: true
+
+class Views::Docs::Bubble < Views::Base
+ def view_template
+ component = "Bubble"
+
+ div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
+ render Docs::Header.new(title: "Bubble", description: "A chat bubble surface for displaying conversational content, with variants, alignment, grouping, and reactions.")
+
+ Heading(level: 2) { "Usage" }
+
+ render Docs::VisualCodeExample.new(title: "Default", context: self) do
+ <<~RUBY
+ Bubble(align: :end) do
+ BubbleContent { "Hey there! what's up?" }
+ end
+ RUBY
+ end
+
+ render Docs::VisualCodeExample.new(title: "Conversation", context: self) do
+ <<~RUBY
+ div(class: "flex flex-col gap-8") do
+ Bubble(align: :end) do
+ BubbleContent { "Hey there! what's up?" }
+ end
+ BubbleGroup do
+ Bubble(variant: :muted) do
+ BubbleContent { "Hey! Want to see chat bubbles?" }
+ end
+ Bubble(variant: :muted) do
+ BubbleContent { "I can group messages, switch sides, and keep the whole thread easy to scan." }
+ BubbleReactions(role: "img", aria_label: "Reaction: thumbs up") do
+ span { "👍" }
+ end
+ end
+ end
+ Bubble(align: :end) do
+ BubbleContent { "Sure. Hit me with your best demo." }
+ end
+ end
+ RUBY
+ end
+
+ Heading(level: 2) { "Variants" }
+
+ render Docs::VisualCodeExample.new(title: "Variants", context: self) do
+ <<~RUBY
+ div(class: "flex flex-col gap-4 w-full") do
+ Bubble(variant: :default) { BubbleContent { "Default" } }
+ Bubble(variant: :secondary) { BubbleContent { "Secondary" } }
+ Bubble(variant: :muted) { BubbleContent { "Muted" } }
+ Bubble(variant: :tinted) { BubbleContent { "Tinted" } }
+ Bubble(variant: :outline) { BubbleContent { "Outline" } }
+ Bubble(variant: :ghost) { BubbleContent { "Ghost — unframed, full width for assistant text or markdown." } }
+ Bubble(variant: :destructive) { BubbleContent { "Destructive — something went wrong." } }
+ end
+ RUBY
+ end
+
+ Heading(level: 2) { "Alignment" }
+
+ render Docs::VisualCodeExample.new(title: "Start and end", context: self) do
+ <<~RUBY
+ div(class: "flex flex-col gap-4 w-full") do
+ Bubble(align: :start, variant: :muted) do
+ BubbleContent { "Aligned to the start (receiver)." }
+ end
+ Bubble(align: :end) do
+ BubbleContent { "Aligned to the end (sender)." }
+ end
+ end
+ RUBY
+ end
+
+ Heading(level: 2) { "Reactions" }
+
+ render Docs::VisualCodeExample.new(title: "Reactions", context: self) do
+ <<~RUBY
+ div(class: "flex flex-col gap-10 w-full py-6") do
+ Bubble(variant: :muted) do
+ BubbleContent { "Reactions anchor to the bubble edge." }
+ BubbleReactions(role: "img", aria_label: "Reactions: thumbs up, fire, eyes, and 2 more") do
+ span { "👍" }
+ span { "🔥" }
+ span { "👀" }
+ span { "+2" }
+ end
+ end
+ Bubble(align: :end) do
+ BubbleContent { "Place them on top and to the start too." }
+ BubbleReactions(side: :top, align: :start, role: "img", aria_label: "Reaction: heart") do
+ span { "❤️" }
+ end
+ end
+ end
+ RUBY
+ end
+
+ Heading(level: 2) { "Group" }
+
+ render Docs::VisualCodeExample.new(title: "Bubble group", context: self) do
+ <<~RUBY
+ BubbleGroup do
+ Bubble(variant: :muted) { BubbleContent { "First message in the group." } }
+ Bubble(variant: :muted) { BubbleContent { "Second one, tighter spacing." } }
+ Bubble(variant: :muted) { BubbleContent { "Third, all stacked together." } }
+ end
+ RUBY
+ end
+
+ Heading(level: 2) { "Link or button bubble" }
+
+ render Docs::VisualCodeExample.new(title: "Interactive content", context: self) do
+ <<~RUBY
+ div(class: "flex flex-col gap-4 w-full") do
+ Bubble(align: :end) do
+ BubbleContent(as: :a, href: "#") { "Tap to open the link →" }
+ end
+ Bubble(variant: :outline) do
+ BubbleContent(as: :button, type: "button") { "Retry sending" }
+ end
+ end
+ RUBY
+ end
+
+ Heading(level: 2) { "With Tooltip" }
+
+ render Docs::VisualCodeExample.new(title: "Reveal metadata on hover", context: self) do
+ <<~RUBY
+ Tooltip do
+ TooltipTrigger(class: "w-fit") do
+ Bubble(variant: :muted, class: "max-w-none") do
+ BubbleContent { "Read 9:41 AM" }
+ end
+ end
+ TooltipContent do
+ Text { "Delivered and read" }
+ end
+ end
+ RUBY
+ end
+
+ Heading(level: 2) { "With Popover" }
+
+ render Docs::VisualCodeExample.new(title: "Surface details on demand", context: self) do
+ <<~RUBY
+ Popover do
+ PopoverTrigger do
+ Bubble(variant: :destructive, class: "max-w-none") do
+ BubbleContent { "Message failed to send" }
+ end
+ end
+ PopoverContent(class: "w-64") do
+ Text(weight: :semibold) { "Delivery error" }
+ Text(size: :sm, class: "text-muted-foreground") { "The recipient's inbox is full. Try again later." }
+ end
+ end
+ RUBY
+ end
+
+ render Components::ComponentSetup::Tabs.new(component_name: component)
+
+ # components
+ render Docs::ComponentsTable.new(component_files(component))
+ end
+ end
+end
diff --git a/gem/lib/ruby_ui/bubble/bubble_group.rb b/gem/lib/ruby_ui/bubble/bubble_group.rb
new file mode 100644
index 00000000..0324751b
--- /dev/null
+++ b/gem/lib/ruby_ui/bubble/bubble_group.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module RubyUI
+ class BubbleGroup < Base
+ def view_template(&)
+ div(**attrs, &)
+ end
+
+ private
+
+ def default_attrs
+ {
+ data: {slot: "bubble-group"},
+ class: "flex min-w-0 flex-col gap-2"
+ }
+ end
+ end
+end
diff --git a/gem/lib/ruby_ui/bubble/bubble_reactions.rb b/gem/lib/ruby_ui/bubble/bubble_reactions.rb
new file mode 100644
index 00000000..4e652aed
--- /dev/null
+++ b/gem/lib/ruby_ui/bubble/bubble_reactions.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module RubyUI
+ class BubbleReactions < Base
+ SIDES = {
+ top: "top-0 -translate-y-3/4",
+ bottom: "bottom-0 translate-y-3/4"
+ }
+
+ ALIGNS = {
+ start: "left-3",
+ end: "right-3"
+ }
+
+ def initialize(side: :bottom, align: :end, **attrs)
+ @side = side
+ @align = align
+ super(**attrs)
+ end
+
+ def view_template(&)
+ div(**attrs, &)
+ end
+
+ private
+
+ def default_attrs
+ {
+ data: {slot: "bubble-reactions", side: @side, align: @align},
+ class: [
+ "absolute z-10 flex w-fit items-center justify-center rounded-full ring-3 ring-card bg-muted shrink-0 gap-1 px-1.5 py-0.5 has-[button]:p-0 text-sm",
+ SIDES[@side],
+ ALIGNS[@align]
+ ]
+ }
+ end
+ end
+end
diff --git a/gem/lib/ruby_ui/message/message.rb b/gem/lib/ruby_ui/message/message.rb
new file mode 100644
index 00000000..6cf005d6
--- /dev/null
+++ b/gem/lib/ruby_ui/message/message.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module RubyUI
+ class Message < Base
+ def initialize(align: :start, **attrs)
+ @align = align
+ super(**attrs)
+ end
+
+ def view_template(&)
+ div(**attrs, &)
+ end
+
+ private
+
+ def default_attrs
+ {
+ data: {slot: "message", align: @align},
+ class: "group/message relative flex w-full min-w-0 gap-2 text-sm data-[align=end]:flex-row-reverse"
+ }
+ end
+ end
+end
diff --git a/gem/lib/ruby_ui/message/message_avatar.rb b/gem/lib/ruby_ui/message/message_avatar.rb
new file mode 100644
index 00000000..9df4b6bf
--- /dev/null
+++ b/gem/lib/ruby_ui/message/message_avatar.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module RubyUI
+ class MessageAvatar < Base
+ def view_template(&)
+ div(**attrs, &)
+ end
+
+ private
+
+ def default_attrs
+ {
+ data: {slot: "message-avatar"},
+ class: "flex w-fit min-w-8 shrink-0 items-center justify-center self-end overflow-hidden rounded-full bg-muted group-has-data-[slot=message-footer]/message:-translate-y-8"
+ }
+ end
+ end
+end
diff --git a/gem/lib/ruby_ui/message/message_content.rb b/gem/lib/ruby_ui/message/message_content.rb
new file mode 100644
index 00000000..0e24f2c7
--- /dev/null
+++ b/gem/lib/ruby_ui/message/message_content.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module RubyUI
+ class MessageContent < Base
+ def view_template(&)
+ div(**attrs, &)
+ end
+
+ private
+
+ def default_attrs
+ {
+ data: {slot: "message-content"},
+ class: "flex w-full min-w-0 flex-col gap-2.5 wrap-break-word group-data-[align=end]/message:[&>[data-slot]]:self-end"
+ }
+ end
+ end
+end
diff --git a/gem/lib/ruby_ui/message/message_docs.rb b/gem/lib/ruby_ui/message/message_docs.rb
new file mode 100644
index 00000000..7488396b
--- /dev/null
+++ b/gem/lib/ruby_ui/message/message_docs.rb
@@ -0,0 +1,173 @@
+# frozen_string_literal: true
+
+class Views::Docs::Message < Views::Base
+ def view_template
+ component = "Message"
+
+ div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
+ render Docs::Header.new(title: "Message", description: "A chat message layout that pairs an avatar with bubbles, headers, and footers. Built on top of Avatar and Bubble.")
+
+ Heading(level: 2) { "Usage" }
+
+ render Docs::VisualCodeExample.new(title: "Conversation", context: self) do
+ <<~RUBY
+ div(class: "flex flex-col gap-6") do
+ Message(align: :end) do
+ MessageAvatar do
+ Avatar(size: :sm) do
+ AvatarImage(src: "https://github.com/joeldrapper.png", alt: "@me")
+ AvatarFallback { "ME" }
+ end
+ end
+ MessageContent do
+ Bubble do
+ BubbleContent { "Deploying to prod real quick." }
+ end
+ end
+ end
+
+ Message do
+ MessageAvatar do
+ Avatar(size: :sm) do
+ AvatarImage(src: "https://github.com/shadcn.png", alt: "@rabbit")
+ AvatarFallback { "R" }
+ end
+ end
+ MessageContent do
+ Bubble(variant: :muted) do
+ BubbleContent { "It's 4:55 PM. On a Friday." }
+ end
+ end
+ end
+
+ Message(align: :end) do
+ MessageAvatar do
+ Avatar(size: :sm) do
+ AvatarImage(src: "https://github.com/joeldrapper.png", alt: "@me")
+ AvatarFallback { "ME" }
+ end
+ end
+ MessageContent do
+ Bubble do
+ BubbleContent { "It's a one-line change." }
+ end
+ MessageFooter { "Delivered" }
+ end
+ end
+
+ Message do
+ MessageAvatar do
+ Avatar(size: :sm) do
+ AvatarImage(src: "https://github.com/shadcn.png", alt: "@rabbit")
+ AvatarFallback { "R" }
+ end
+ end
+ MessageContent do
+ BubbleGroup do
+ Bubble(variant: :muted) do
+ BubbleContent { "It's always a one-line change 😭." }
+ end
+ Bubble(variant: :muted) do
+ BubbleContent { "Alright, let me take a look." }
+ BubbleReactions(role: "img", aria_label: "Reaction: thumbs up") do
+ span { "👍" }
+ end
+ end
+ end
+ end
+ end
+ end
+ RUBY
+ end
+
+ Heading(level: 2) { "With header" }
+
+ render Docs::VisualCodeExample.new(title: "Header and footer", context: self) do
+ <<~RUBY
+ Message do
+ MessageAvatar do
+ Avatar(size: :sm) do
+ AvatarImage(src: "https://github.com/shadcn.png", alt: "@oliver")
+ AvatarFallback { "OL" }
+ end
+ end
+ MessageContent do
+ MessageHeader { "Oliver" }
+ Bubble(variant: :muted) do
+ BubbleContent { "Pushed the fix, can you review?" }
+ end
+ MessageFooter { "9:41 AM" }
+ end
+ end
+ RUBY
+ end
+
+ Heading(level: 2) { "Alignment" }
+
+ render Docs::VisualCodeExample.new(title: "Sender and receiver", context: self) do
+ <<~RUBY
+ div(class: "flex flex-col gap-6") do
+ Message do
+ MessageAvatar do
+ Avatar(size: :sm) do
+ AvatarImage(src: "https://github.com/shadcn.png", alt: "@rabbit")
+ AvatarFallback { "R" }
+ end
+ end
+ MessageContent do
+ Bubble(variant: :muted) { BubbleContent { "Aligned to the start." } }
+ end
+ end
+ Message(align: :end) do
+ MessageAvatar do
+ Avatar(size: :sm) do
+ AvatarImage(src: "https://github.com/joeldrapper.png", alt: "@me")
+ AvatarFallback { "ME" }
+ end
+ end
+ MessageContent do
+ Bubble { BubbleContent { "Aligned to the end." } }
+ end
+ end
+ end
+ RUBY
+ end
+
+ Heading(level: 2) { "Group" }
+
+ render Docs::VisualCodeExample.new(title: "Message group", context: self) do
+ <<~RUBY
+ MessageGroup do
+ Message do
+ MessageAvatar do
+ Avatar(size: :sm) do
+ AvatarImage(src: "https://github.com/shadcn.png", alt: "@rabbit")
+ AvatarFallback { "R" }
+ end
+ end
+ MessageContent do
+ Bubble(variant: :muted) { BubbleContent { "First message." } }
+ end
+ end
+ Message do
+ MessageAvatar do
+ Avatar(size: :sm) do
+ AvatarImage(src: "https://github.com/shadcn.png", alt: "@rabbit")
+ AvatarFallback { "R" }
+ end
+ end
+ MessageContent do
+ Bubble(variant: :muted) { BubbleContent { "Second, tighter spacing." } }
+ end
+ end
+ end
+ RUBY
+ end
+
+ render Components::ComponentSetup::Tabs.new(component_name: component)
+
+ # components
+ render Docs::ComponentsTable.new(component_files(component))
+ end
+ end
+end
diff --git a/gem/lib/ruby_ui/message/message_footer.rb b/gem/lib/ruby_ui/message/message_footer.rb
new file mode 100644
index 00000000..ad4042d6
--- /dev/null
+++ b/gem/lib/ruby_ui/message/message_footer.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module RubyUI
+ class MessageFooter < Base
+ def view_template(&)
+ div(**attrs, &)
+ end
+
+ private
+
+ def default_attrs
+ {
+ data: {slot: "message-footer"},
+ class: "flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-data-[align=end]/message:justify-end group-has-data-[variant=ghost]/message:px-0"
+ }
+ end
+ end
+end
diff --git a/gem/lib/ruby_ui/message/message_group.rb b/gem/lib/ruby_ui/message/message_group.rb
new file mode 100644
index 00000000..14210d57
--- /dev/null
+++ b/gem/lib/ruby_ui/message/message_group.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module RubyUI
+ class MessageGroup < Base
+ def view_template(&)
+ div(**attrs, &)
+ end
+
+ private
+
+ def default_attrs
+ {
+ data: {slot: "message-group"},
+ class: "flex min-w-0 flex-col gap-2"
+ }
+ end
+ end
+end
diff --git a/gem/lib/ruby_ui/message/message_header.rb b/gem/lib/ruby_ui/message/message_header.rb
new file mode 100644
index 00000000..f3810c74
--- /dev/null
+++ b/gem/lib/ruby_ui/message/message_header.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module RubyUI
+ class MessageHeader < Base
+ def view_template(&)
+ div(**attrs, &)
+ end
+
+ private
+
+ def default_attrs
+ {
+ data: {slot: "message-header"},
+ class: "flex max-w-full min-w-0 items-center px-3 text-xs font-medium text-muted-foreground group-has-data-[variant=ghost]/message:px-0"
+ }
+ end
+ end
+end
diff --git a/gem/test/ruby_ui/bubble_test.rb b/gem/test/ruby_ui/bubble_test.rb
new file mode 100644
index 00000000..105a04c2
--- /dev/null
+++ b/gem/test/ruby_ui/bubble_test.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class RubyUI::BubbleTest < ComponentTest
+ def test_renders_content
+ output = phlex do
+ RubyUI.Bubble do
+ RubyUI.BubbleContent { "Hello" }
+ end
+ end
+
+ assert_match(/Hello/, output)
+ assert_match(/data-slot="bubble"/, output)
+ assert_match(/data-slot="bubble-content"/, output)
+ end
+
+ def test_default_variant_and_align
+ output = phlex { RubyUI.Bubble { RubyUI.BubbleContent { "Hi" } } }
+
+ assert_match(/data-variant="default"/, output)
+ assert_match(/data-align="start"/, output)
+ end
+
+ def test_variant_and_align_attributes
+ output = phlex do
+ RubyUI.Bubble(variant: :muted, align: :end) { RubyUI.BubbleContent { "Hi" } }
+ end
+
+ assert_match(/data-variant="muted"/, output)
+ assert_match(/data-align="end"/, output)
+ end
+
+ def test_reactions_defaults_to_bottom_end
+ output = phlex do
+ RubyUI.Bubble do
+ RubyUI.BubbleContent { "Hi" }
+ RubyUI.BubbleReactions { "👍" }
+ end
+ end
+
+ assert_match(/data-slot="bubble-reactions"/, output)
+ assert_match(/data-side="bottom"/, output)
+ assert_match(/data-align="end"/, output)
+ end
+
+ def test_reactions_side_and_align
+ output = phlex do
+ RubyUI.BubbleReactions(side: :top, align: :start) { "🔥" }
+ end
+
+ assert_match(/data-side="top"/, output)
+ assert_match(/data-align="start"/, output)
+ end
+
+ def test_content_renders_as_anchor
+ output = phlex do
+ RubyUI.Bubble { RubyUI.BubbleContent(as: :a, href: "#") { "Open" } }
+ end
+
+ assert_match(/]*data-slot="bubble-content"[^>]*href="#"/, output)
+ assert_match(/Open/, output)
+ end
+
+ def test_content_renders_as_button
+ output = phlex do
+ RubyUI.Bubble { RubyUI.BubbleContent(as: :button, type: "button") { "Retry" } }
+ end
+
+ assert_match(/