Sidebar
A composable, themeable and customizable sidebar component.
Usage
Example
SidebarWrapper do
Sidebar(collapsible: :icon) do
SidebarHeader do
SidebarMenu do
SidebarMenuItem do
SidebarMenuButton(as: :a, href: "#", tooltip: "Search") do
search_icon()
span { "Search" }
end
end
SidebarMenuItem do
SidebarMenuButton(as: :a, href: "#", active: true, tooltip: "Home") do
home_icon()
span { "Home" }
end
end
SidebarMenuItem do
SidebarMenuButton(as: :a, href: "#", tooltip: "Inbox") do
inbox_icon()
span { "Inbox" }
SidebarMenuBadge { 4 }
end
end
end
end
SidebarContent do
SidebarGroup do
SidebarGroupLabel { "Favorites" }
SidebarMenu do
FAVORITES.each do |item|
SidebarMenuItem do
SidebarMenuButton(as: :a, href: "#") do
span { item[:emoji] }
span { item[:name] }
end
DropdownMenu(options: { strategy: "fixed", placement: "right-start" }) do
SidebarMenuAction(
data: {
ruby_ui__dropdown_menu_target: "trigger",
action: "click->ruby-ui--dropdown-menu#toggle"
}
) do
ellipsis_icon()
span(class: "sr-only") { "More" }
end
DropdownMenuContent do
DropdownMenuItem(href: '#') { "Profile" }
DropdownMenuItem(href: '#') { "Billing" }
DropdownMenuItem(href: '#') { "Team" }
DropdownMenuItem(href: '#') { "Subscription" }
end
end
end
end
end
end
SidebarGroup do
SidebarGroupLabel { "Workspaces" }
SidebarMenu do
WORKSPACES.each do |item|
SidebarMenuItem do
SidebarMenuButton(as: :a, href: "#") do
span { item[:emoji] }
span { item[:name] }
end
DropdownMenu() do
SidebarMenuAction(
data: {
ruby_ui__dropdown_menu_target: "trigger",
action: "click->ruby-ui--dropdown-menu#toggle"
}
) do
ellipsis_icon()
span(class: "sr-only") { "More" }
end
DropdownMenuContent do
DropdownMenuItem(href: '#') { "Profile" }
DropdownMenuItem(href: '#') { "Billing" }
DropdownMenuItem(href: '#') { "Team" }
DropdownMenuItem(href: '#') { "Subscription" }
end
end
end
end
end
end
SidebarGroup(class: "mt-auto") do
SidebarGroupContent do
SidebarMenu do
nav_secondary.each do |item|
SidebarMenuItem do
SidebarMenuButton(as: :a, href: "#", tooltip: item[:label]) do
item[:icon].call
span { item[:label] }
end
end
end
end
end
end
end
SidebarRail()
end
SidebarInset do
header(class: "flex h-16 shrink-0 items-center gap-2 border-b px-4") do
SidebarTrigger(class: "-ml-1")
end
end
end
Inset variant
SidebarWrapper do
Sidebar(variant: :inset) do
SidebarHeader do
SidebarMenu do
SidebarMenuItem do
SidebarMenuButton(as: :a, href: "#") do
search_icon()
span { "Search" }
end
end
SidebarMenuItem do
SidebarMenuButton(as: :a, href: "#", active: true) do
home_icon()
span { "Home" }
end
end
SidebarMenuItem do
SidebarMenuButton(as: :a, href: "#") do
inbox_icon()
span { "Inbox" }
SidebarMenuBadge { 4 }
end
end
end
end
SidebarContent do
SidebarGroup do
SidebarGroupLabel { "Favorites" }
SidebarMenu do
FAVORITES.each do |item|
SidebarMenuItem do
SidebarMenuButton(as: :a, href: "#") do
span { item[:emoji] }
span { item[:name] }
end
DropdownMenu(options: { strategy: "fixed", placement: "right-start" }) do
SidebarMenuAction(
data: {
ruby_ui__dropdown_menu_target: "trigger",
action: "click->ruby-ui--dropdown-menu#toggle"
}
) do
ellipsis_icon()
span(class: "sr-only") { "More" }
end
DropdownMenuContent do
DropdownMenuItem(href: '#') { "Profile" }
DropdownMenuItem(href: '#') { "Billing" }
DropdownMenuItem(href: '#') { "Team" }
DropdownMenuItem(href: '#') { "Subscription" }
end
end
end
end
end
end
SidebarGroup do
SidebarGroupLabel { "Workspaces" }
SidebarMenu do
WORKSPACES.each do |item|
SidebarMenuItem do
SidebarMenuButton(as: :a, href: "#") do
span { item[:emoji] }
span { item[:name] }
end
DropdownMenu() do
SidebarMenuAction(
data: {
ruby_ui__dropdown_menu_target: "trigger",
action: "click->ruby-ui--dropdown-menu#toggle"
}
) do
ellipsis_icon()
span(class: "sr-only") { "More" }
end
DropdownMenuContent do
DropdownMenuItem(href: '#') { "Profile" }
DropdownMenuItem(href: '#') { "Billing" }
DropdownMenuItem(href: '#') { "Team" }
DropdownMenuItem(href: '#') { "Subscription" }
end
end
end
end
end
end
SidebarGroup(class: "mt-auto") do
SidebarGroupContent do
SidebarMenu do
nav_secondary.each do |item|
SidebarMenuItem do
SidebarMenuButton(as: :a, href: "#") do
item[:icon].call
span { item[:label] }
end
end
end
end
end
end
end
SidebarRail()
end
SidebarInset do
header(class: "flex h-16 shrink-0 items-center gap-2 border-b px-4") do
SidebarTrigger(class: "-ml-1")
end
end
end
Tooltips
With collapsible: :icon, pass tooltip: to SidebarMenuButton. The label shows on the right only while the sidebar is collapsed on desktop.
SidebarMenuButton(as: :a, href: "#", tooltip: "Inbox") do
inbox_icon()
span { "Inbox" }
end
Keyboard shortcut and state
On desktop, Ctrl+B (Cmd+B on macOS) toggles the sidebar. Each desktop toggle saves the state in the sidebar_state cookie and dispatches ruby-ui--sidebar:change with detail.open; on mobile the trigger opens the sheet instead. Read the cookie on the server to render the last desktop state.
# Initial state from the cookie
Sidebar(open: cookies[:sidebar_state] != "false") { ... }
# React to changes in your own Stimulus controller
SidebarWrapper(data: {action: "ruby-ui--sidebar:change->layout#resize"}) { ... }
Dialog variant
Dialog(data: {action: "ruby-ui--dialog:connect->ruby-ui--dialog#open"}) do
DialogTrigger do
Button { "Open Dialog" }
end
DialogContent(class: "grid overflow-hidden p-0 md:max-h-[500px] md:max-w-[700px] lg:max-w-[800px]") do
SidebarWrapper(class: "items-start") do
Sidebar(collapsible: :none, class: "hidden md:flex") do
SidebarContent do
SidebarGroup do
SidebarGroupContent do
SidebarMenu do
SidebarMenuItem do
SidebarMenuButton(as: :a, href: "#") do
search_icon()
span { "Search" }
end
end
SidebarMenuItem do
SidebarMenuButton(as: :a, href: "#", active: true) do
home_icon()
span { "Home" }
end
end
SidebarMenuItem do
SidebarMenuButton(as: :a, href: "#") do
inbox_icon()
span { "Inbox" }
end
end
end
end
end
end
end
main(class: "flex h-[480px] flex-1 flex-col overflow-hidden") do
end
end
end
end
Installation
Using RubyUI CLI
Run the install command
rails g ruby_ui:component Sidebar
Manual installation
1
Add RubyUI::CollapsibleSidebar to app/components/ruby_ui/sidebar/collapsible_sidebar.rb
# frozen_string_literal: true
module RubyUI
class CollapsibleSidebar < Base
def initialize(side: :left, variant: :sidebar, collapsible: :offcanvas, open: true, **attrs)
@side = side
@variant = variant
@collapsible = collapsible
@open = open
super(**attrs)
end
# Like shadcn, the caller's class/attrs go to the fixed sidebar container,
# not to the outer peer div that only carries the state.
def view_template(&)
MobileSidebar(side: @side, **attrs, &)
div(**sidebar_attrs) do
div(**gap_element_attrs)
div(**mix(content_wrapper_attrs, attrs)) do
div(**content_attrs, &)
end
end
end
private
def sidebar_attrs
{
class: "group peer hidden text-sidebar-foreground md:block",
data: {
state: @open ? "expanded" : "collapsed",
collapsible: @open ? "" : @collapsible,
variant: @variant,
side: @side,
slot: "sidebar",
collapsible_kind: @collapsible,
ruby_ui__sidebar_target: "sidebar"
}
}
end
def gap_element_attrs
{
class: [
"relative w-(--sidebar-width) bg-transparent transition-[width]",
"duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant_classes
],
data: {slot: "sidebar-gap"}
}
end
def content_wrapper_attrs
{
class: [
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width)",
"transition-[left,right,width] duration-200 ease-linear md:flex",
content_wrapper_side_classes,
content_wrapper_variant_classes
],
data: {slot: "sidebar-container", side: @side}
}
end
def content_attrs
{
class: [
"flex size-full flex-col bg-sidebar",
"group-data-[variant=floating]:rounded-lg",
"group-data-[variant=floating]:shadow-sm",
"group-data-[variant=floating]:ring-1",
"group-data-[variant=floating]:ring-sidebar-border"
],
data: {
slot: "sidebar-inner",
sidebar: "sidebar"
}
}
end
def variant_classes
if %i[floating inset].include?(@variant)
"group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
else
"group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
end
end
def content_wrapper_side_classes
return "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]" if @side == :left
"right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]"
end
def content_wrapper_variant_classes
if %i[floating inset].include?(@variant)
"p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
else
"group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l"
end
end
end
end
2
Add RubyUI::MobileSidebar to app/components/ruby_ui/sidebar/mobile_sidebar.rb
# frozen_string_literal: true
module RubyUI
class MobileSidebar < Base
SIDEBAR_WIDTH_MOBILE = "18rem"
def initialize(side: :left, **attrs)
@side = side
super(**attrs)
end
def view_template(&)
Sheet(**attrs) do
SheetContent(
side: @side,
show_close_button: false,
class: "w-[var(--sidebar-width)] bg-sidebar p-0 text-sidebar-foreground",
style: {
"--sidebar-width": SIDEBAR_WIDTH_MOBILE
},
data: {
# slot! replaces SheetContent's own data-slot instead of joining it.
slot!: "sidebar",
sidebar: "sidebar",
mobile: "true"
}
) do
SheetHeader(class: "sr-only") do
SheetTitle { "Sidebar" }
SheetDescription { "Displays the mobile sidebar." }
end
div(class: "flex h-full w-full flex-col", &)
end
end
end
private
def default_attrs
{
data: {
ruby_ui__sidebar_target: "mobileSidebar",
action: "ruby--ui-sidebar:open->ruby-ui--sheet#open:self"
}
}
end
end
end
3
Add RubyUI::NonCollapsibleSidebar to app/components/ruby_ui/sidebar/non_collapsible_sidebar.rb
# frozen_string_literal: true
module RubyUI
class NonCollapsibleSidebar < Base
def view_template(&)
div(**attrs, &)
end
private
def default_attrs
{
class: "flex h-full w-(--sidebar-width) flex-col bg-sidebar text-sidebar-foreground",
# Always open: lets SidebarMenuButton tooltips hide, as shadcn does
# (its provider state stays "expanded").
data: {slot: "sidebar", state: "expanded"}
}
end
end
end
4
Add RubyUI::Sidebar to app/components/ruby_ui/sidebar/sidebar.rb
# frozen_string_literal: true
module RubyUI
class Sidebar < Base
SIDES = %i[left right].freeze
VARIANTS = %i[sidebar floating inset].freeze
COLLAPSIBLES = %i[offcanvas icon none].freeze
def initialize(side: :left, variant: :sidebar, collapsible: :offcanvas, open: true, **attrs)
raise ArgumentError, "Invalid side: #{side}." unless SIDES.include?(side.to_sym)
raise ArgumentError, "Invalid variant: #{variant}." unless VARIANTS.include?(variant.to_sym)
raise ArgumentError, "Invalid collapsible: #{collapsible}." unless COLLAPSIBLES.include?(collapsible.to_sym)
@side = side.to_sym
@variant = variant.to_sym
@collapsible = collapsible.to_sym
@open = open
super(**attrs)
end
def view_template(&)
if @collapsible == :none
NonCollapsibleSidebar(**attrs, &)
else
CollapsibleSidebar(side: @side, variant: @variant, collapsible: @collapsible, open: @open, **attrs, &)
end
end
end
end
5
Add RubyUI::SidebarContent to app/components/ruby_ui/sidebar/sidebar_content.rb
# frozen_string_literal: true
module RubyUI
class SidebarContent < Base
def view_template(&)
div(**attrs, &)
end
private
def default_attrs
{
class: "flex min-h-0 flex-1 flex-col gap-2 overflow-auto [scrollbar-width:none] group-data-[collapsible=icon]:overflow-hidden [&::-webkit-scrollbar]:hidden",
data: {
slot: "sidebar-content",
sidebar: "content"
}
}
end
end
end
6
Add RubyUI::SidebarDocs to app/components/ruby_ui/sidebar/sidebar_docs.rb
# frozen_string_literal: true
class Views::Docs::Sidebar < Views::Base
def view_template
component = "Sidebar"
div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
render Docs::Header.new(title: "Sidebar", description: "A composable, themeable and customizable sidebar component.")
Heading(level: 2) { "Usage" }
Alert do
info_icon
AlertTitle { "Requirements" }
AlertDescription { "The sidebar component depends on the following components:" }
ul(class: "list-disc list-inside") do
li do
InlineLink(href: docs_sheet_path, target: "_blank", class: "inline-flex items-center gap-2") do
span { "Sheet" }
external_icon_link
end
end
li do
div(class: "inline-flex items-center gap-2") do
InlineLink(href: docs_separator_path, target: "_blank") { "Separator" }
external_icon_link
end
end
end
end
render Docs::VisualCodeExample.new(title: "Example", src: "/docs/sidebar/example", context: self) do
Views::Docs::Sidebar::Example::CODE
end
render Docs::VisualCodeExample.new(title: "Inset variant", src: "/docs/sidebar/inset", context: self) do
Views::Docs::Sidebar::InsetExample::CODE
end
render Docs::VisualCodeExample.new(title: "Dialog variant", context: self) do
<<~RUBY
Dialog(data: {action: "ruby-ui--dialog:connect->ruby-ui--dialog#open"}) do
DialogTrigger do
Button { "Open Dialog" }
end
DialogContent(class: "grid overflow-hidden p-0 md:max-h-[500px] md:max-w-[700px] lg:max-w-[800px]") do
SidebarWrapper(class: "items-start") do
Sidebar(collapsible: :none, class: "hidden md:flex") do
SidebarContent do
SidebarGroup do
SidebarGroupContent do
SidebarMenu do
SidebarMenuItem do
SidebarMenuButton(as: :a, href: "#") do
search_icon()
span { "Search" }
end
end
SidebarMenuItem do
SidebarMenuButton(as: :a, href: "#", active: true) do
home_icon()
span { "Home" }
end
end
SidebarMenuItem do
SidebarMenuButton(as: :a, href: "#") do
inbox_icon()
span { "Inbox" }
end
end
end
end
end
end
end
main(class: "flex h-[480px] flex-1 flex-col overflow-hidden") do
end
end
end
end
RUBY
end
render Components::ComponentSetup::Tabs.new(component_name: component)
render Docs::ComponentsTable.new(component_files(component))
end
end
def search_icon
svg(
xmlns: "http://www.w3.org/2000/svg",
width: "24",
height: "24",
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
stroke_width: "2",
stroke_linecap: "round",
stroke_linejoin: "round",
class: "lucide lucide-search"
) do |s|
s.circle(cx: "11", cy: "11", r: "8")
s.path(d: "M21 21L16.7 16.7")
end
end
def home_icon
svg(
xmlns: "http://www.w3.org/2000/svg",
width: "24",
height: "24",
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
stroke_width: "2",
stroke_linecap: "round",
stroke_linejoin: "round",
class: "lucide lucide-house"
) do |s|
s.path(d: "M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8")
s.path(d: "M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z")
end
end
def inbox_icon
svg(
xmlns: "http://www.w3.org/2000/svg",
width: "24",
height: "24",
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
stroke_width: "2",
stroke_linecap: "round",
stroke_linejoin: "round",
class: "lucide lucide-inbox"
) do |s|
s.polyline(points: "22 12 16 12 14 15 10 15 8 12 2 12")
s.path(d: "M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z")
end
end
def external_icon_link
svg(
xmlns: "http://www.w3.org/2000/svg",
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
stroke_width: "2",
stroke_linecap: "round",
stroke_linejoin: "round",
class: "lucide lucide-external-link-icon lucide-external-link size-3"
) do |s|
s.path(d: "M15 3h6v6")
s.path(d: "M10 14 21 3")
s.path(d: "M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6")
end
end
def info_icon
svg(
xmlns: "http://www.w3.org/2000/svg",
viewbox: "0 0 24 24",
fill: "currentColor",
class: "w-5 h-5"
) do |s|
s.path(
fill_rule: "evenodd",
d:
"M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm8.706-1.442c1.146-.573 2.437.463 2.126 1.706l-.709 2.836.042-.02a.75.75 0 01.67 1.34l-.04.022c-1.147.573-2.438-.463-2.127-1.706l.71-2.836-.042.02a.75.75 0 11-.671-1.34l.041-.022zM12 9a.75.75 0 100-1.5.75.75 0 000 1.5z",
clip_rule: "evenodd"
)
end
end
end
7
Add RubyUI::SidebarFooter to app/components/ruby_ui/sidebar/sidebar_footer.rb
# frozen_string_literal: true
module RubyUI
class SidebarFooter < Base
def view_template(&)
div(**attrs, &)
end
private
def default_attrs
{
class: "flex flex-col gap-2 p-2",
data: {
slot: "sidebar-footer",
sidebar: "footer"
}
}
end
end
end
8
Add RubyUI::SidebarGroup to app/components/ruby_ui/sidebar/sidebar_group.rb
# frozen_string_literal: true
module RubyUI
class SidebarGroup < Base
def view_template(&)
div(**attrs, &)
end
private
def default_attrs
{
class: "relative flex w-full min-w-0 flex-col p-2",
data: {
slot: "sidebar-group",
sidebar: "group"
}
}
end
end
end
9
Add RubyUI::SidebarGroupAction to app/components/ruby_ui/sidebar/sidebar_group_action.rb
# frozen_string_literal: true
module RubyUI
class SidebarGroupAction < Base
def initialize(as: :button, **attrs)
@as = as
super(**attrs)
end
def view_template(&)
tag(@as, **attrs, &)
end
private
def default_attrs
{
class: [
"absolute right-3 top-3.5 flex aspect-square w-5 items-center",
"justify-center rounded-md p-0 text-sidebar-foreground",
"outline-none ring-sidebar-ring transition-transform",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
"focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"after:absolute after:-inset-2 after:md:hidden",
"group-data-[collapsible=icon]:hidden"
],
data: {
slot: "sidebar-group-action",
sidebar: "group-action"
}
}
end
end
end
10
Add RubyUI::SidebarGroupContent to app/components/ruby_ui/sidebar/sidebar_group_content.rb
# frozen_string_literal: true
module RubyUI
class SidebarGroupContent < Base
def view_template(&)
div(**attrs, &)
end
private
def default_attrs
{
class: "w-full text-sm",
data: {
slot: "sidebar-group-content",
sidebar: "group-content"
}
}
end
end
end
11
Add RubyUI::SidebarGroupLabel to app/components/ruby_ui/sidebar/sidebar_group_label.rb
# frozen_string_literal: true
module RubyUI
class SidebarGroupLabel < Base
def initialize(as: :div, **attrs)
@as = as
super(**attrs)
end
def view_template(&)
tag(@as, **attrs, &)
end
private
def default_attrs
{
class: [
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs",
"font-medium text-sidebar-foreground/70 outline-none",
"ring-sidebar-ring transition-[margin,opacity] duration-200",
"ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0"
],
data: {
slot: "sidebar-group-label",
sidebar: "group-label"
}
}
end
end
end
12
Add RubyUI::SidebarHeader to app/components/ruby_ui/sidebar/sidebar_header.rb
# frozen_string_literal: true
module RubyUI
class SidebarHeader < Base
def view_template(&)
div(**attrs, &)
end
private
def default_attrs
{
class: "flex flex-col gap-2 p-2",
data: {
slot: "sidebar-header",
sidebar: "header"
}
}
end
end
end
13
Add RubyUI::SidebarInput to app/components/ruby_ui/sidebar/sidebar_input.rb
# frozen_string_literal: true
module RubyUI
class SidebarInput < Base
def view_template(&)
Input(**attrs, &)
end
private
def default_attrs
{
class: "h-8 w-full bg-background shadow-none focus-visible:ring-2 focus-visible:ring-sidebar-ring",
data: {
slot: "sidebar-input",
sidebar: "input"
}
}
end
end
end
14
Add RubyUI::SidebarInset to app/components/ruby_ui/sidebar/sidebar_inset.rb
# frozen_string_literal: true
module RubyUI
class SidebarInset < Base
def view_template(&)
main(**attrs, &)
end
private
def default_attrs
{
class: [
"relative flex w-full flex-1 flex-col bg-background",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0",
"md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm",
"md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2"
],
data: {slot: "sidebar-inset"}
}
end
end
end
15
Add RubyUI::SidebarMenu to app/components/ruby_ui/sidebar/sidebar_menu.rb
# frozen_string_literal: true
module RubyUI
class SidebarMenu < Base
def view_template(&)
ul(**attrs, &)
end
private
def default_attrs
{
class: "flex w-full min-w-0 flex-col gap-1",
data: {
slot: "sidebar-menu",
sidebar: "menu"
}
}
end
end
end
16
Add RubyUI::SidebarMenuAction to app/components/ruby_ui/sidebar/sidebar_menu_action.rb
# frozen_string_literal: true
module RubyUI
class SidebarMenuAction < Base
def initialize(as: :button, show_on_hover: false, **attrs)
@as = as
@show_on_hover = show_on_hover
super(**attrs)
end
def view_template(&)
tag(@as, **attrs, &)
end
private
def default_attrs
{
class: [
"absolute right-1 top-1.5 flex aspect-square w-5 items-center",
"justify-center rounded-md p-0 text-sidebar-foreground outline-none",
"ring-sidebar-ring transition-transform hover:bg-sidebar-accent",
"hover:text-sidebar-accent-foreground focus-visible:ring-2",
"peer-hover/menu-button:text-sidebar-accent-foreground",
"[&>svg]:size-4 [&>svg]:shrink-0",
"after:absolute after:-inset-2 after:md:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
show_on_hover_classes
],
data: {
slot: "sidebar-menu-action",
sidebar: "menu-action"
}
}
end
def show_on_hover_classes
return unless @show_on_hover
[
"group-focus-within/menu-item:opacity-100",
"group-hover/menu-item:opacity-100 aria-expanded:opacity-100",
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground md:opacity-0"
].join(" ")
end
end
end
17
Add RubyUI::SidebarMenuBadge to app/components/ruby_ui/sidebar/sidebar_menu_badge.rb
# frozen_string_literal: true
module RubyUI
class SidebarMenuBadge < Base
def view_template(&)
div(**attrs, &)
end
private
def default_attrs
{
class: [
"pointer-events-none absolute right-1 flex h-5 min-w-5 select-none",
"items-center justify-center rounded-md px-1 text-xs font-medium",
"tabular-nums text-sidebar-foreground",
"peer-hover/menu-button:text-sidebar-accent-foreground",
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden"
],
data: {
slot: "sidebar-menu-badge",
sidebar: "menu-badge"
}
}
end
end
end
18
Add RubyUI::SidebarMenuButton to app/components/ruby_ui/sidebar/sidebar_menu_button.rb
# frozen_string_literal: true
module RubyUI
class SidebarMenuButton < Base
VARIANT_CLASSES = {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline: [
"bg-background shadow-[0_0_0_1px_var(--sidebar-border)]",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_var(--sidebar-accent)]"
].join(" ")
}.freeze
SIZE_CLASSES = {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0"
}.freeze
# The tooltip controller places the content right after this button, inside
# its own sidebar, so it hides from that sidebar's state alone: shown only
# while the desktop sidebar around it is collapsed (shadcn:
# hidden={state !== "collapsed" || isMobile}). A second, expanded sidebar
# on the page no longer hides it.
TOOLTIP_CLASSES = "[[data-slot=sidebar]:not([data-state=collapsed])_&]:hidden max-md:hidden"
# tooltip: label shown on the right when the sidebar is collapsed to icons.
def initialize(as: :button, variant: :default, size: :default, active: false, tooltip: nil, **attrs)
raise ArgumentError, "Invalid variant: #{variant}" unless VARIANT_CLASSES.key?(variant)
raise ArgumentError, "Invalid size: #{size}" unless SIZE_CLASSES.key?(size)
@as = as
@variant = variant
@size = size
@active = active
@tooltip = tooltip
super(**attrs)
end
def view_template(&block)
return tag(@as, **attrs, &block) unless @tooltip
tag(@as, **mix(attrs, tooltip_attrs)) do
# First child, so [&>span:last-child]:truncate still finds the label.
TooltipContent(side: :right, class: TOOLTIP_CLASSES) { @tooltip }
yield if block
end
end
private
def tooltip_attrs
{
data: {
controller: "ruby-ui--tooltip",
ruby_ui__tooltip_target: "trigger"
}
}
end
def default_attrs
{
class: [
"peer/menu-button group/menu-button flex w-full items-center gap-2 overflow-hidden",
"rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring",
"transition-[width,height,padding] focus-visible:ring-2",
"active:bg-sidebar-accent active:text-sidebar-accent-foreground",
"disabled:pointer-events-none disabled:opacity-50",
"group-has-[[data-sidebar=menu-action]]/menu-item:pr-8",
"aria-disabled:pointer-events-none aria-disabled:opacity-50",
"data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium",
"data-[active=true]:text-sidebar-accent-foreground",
"data-open:hover:bg-sidebar-accent data-open:hover:text-sidebar-accent-foreground",
"aria-expanded:hover:bg-sidebar-accent aria-expanded:hover:text-sidebar-accent-foreground",
"group-data-[collapsible=icon]:!size-8",
"group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate",
"[&_svg]:size-4 [&_svg]:shrink-0",
VARIANT_CLASSES[@variant],
SIZE_CLASSES[@size]
],
data: {
slot: "sidebar-menu-button",
sidebar: "menu-button",
size: @size,
active: @active.to_s
}
}
end
end
end
19
Add RubyUI::SidebarMenuItem to app/components/ruby_ui/sidebar/sidebar_menu_item.rb
# frozen_string_literal: true
module RubyUI
class SidebarMenuItem < Base
def view_template(&)
li(**attrs, &)
end
private
def default_attrs
{
class: "group/menu-item relative",
data: {
slot: "sidebar-menu-item",
sidebar: "menu-item"
}
}
end
end
end
20
Add RubyUI::SidebarMenuSkeleton to app/components/ruby_ui/sidebar/sidebar_menu_skeleton.rb
# frozen_string_literal: true
module RubyUI
class SidebarMenuSkeleton < Base
def initialize(show_icon: false, **attrs)
@show_icon = show_icon
super(**attrs)
end
def view_template(&)
div(**attrs) do
Skeleton(class: "size-4 rounded-md", data: {sidebar: "menu-skeleton-icon"}) if @show_icon
Skeleton(
class: "h-4 max-w-[var(--skeleton-width)] flex-1",
data: {sidebar: "menu-skeleton-text"},
style: {"--skeleton-width" => "#{skeleton_width}%"}
)
end
end
private
def default_attrs
{
class: "flex h-8 items-center gap-2 rounded-md px-2",
data: {
slot: "sidebar-menu-skeleton",
sidebar: "menu-skeleton"
}
}
end
def skeleton_width
@_skeleton_width ||= rand(50..89)
end
end
end
21
Add RubyUI::SidebarMenuSub to app/components/ruby_ui/sidebar/sidebar_menu_sub.rb
# frozen_string_literal: true
module RubyUI
class SidebarMenuSub < Base
def view_template(&)
ul(**attrs, &)
end
private
def default_attrs
{
class: [
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l",
"border-sidebar-border px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden"
],
data: {
slot: "sidebar-menu-sub",
sidebar: "menu-sub"
}
}
end
end
end
22
Add RubyUI::SidebarMenuSubButton to app/components/ruby_ui/sidebar/sidebar_menu_sub_button.rb
# frozen_string_literal: true
module RubyUI
class SidebarMenuSubButton < Base
SIZE_CLASSES = {
sm: "text-xs",
md: "text-sm"
}.freeze
def initialize(as: :a, size: :md, active: false, **attrs)
raise ArgumentError, "Invalid size: #{size}" unless SIZE_CLASSES.key?(size)
@as = as
@size = size
@active = active
super(**attrs)
end
def view_template(&)
tag(@as, **attrs, &)
end
private
def default_attrs
{
class: [
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden",
"rounded-md px-2 text-sidebar-foreground outline-none",
"ring-sidebar-ring hover:bg-sidebar-accent",
"hover:text-sidebar-accent-foreground focus-visible:ring-2",
"active:bg-sidebar-accent active:text-sidebar-accent-foreground",
"disabled:pointer-events-none disabled:opacity-50",
"aria-disabled:pointer-events-none aria-disabled:opacity-50",
"[&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"[&>svg]:text-sidebar-accent-foreground",
"data-[active=true]:bg-sidebar-accent",
"data-[active=true]:text-sidebar-accent-foreground",
"group-data-[collapsible=icon]:hidden",
SIZE_CLASSES[@size]
],
data: {
slot: "sidebar-menu-sub-button",
sidebar: "menu-sub-button",
size: @size,
active: @active.to_s
}
}
end
end
end
23
Add RubyUI::SidebarMenuSubItem to app/components/ruby_ui/sidebar/sidebar_menu_sub_item.rb
# frozen_string_literal: true
module RubyUI
class SidebarMenuSubItem < Base
def view_template(&)
li(**attrs, &)
end
private
def default_attrs
{
class: "group/menu-sub-item relative",
data: {
slot: "sidebar-menu-sub-item",
sidebar: "menu-sub-item"
}
}
end
end
end
24
Add RubyUI::SidebarRail to app/components/ruby_ui/sidebar/sidebar_rail.rb
# frozen_string_literal: true
module RubyUI
class SidebarRail < Base
def view_template(&)
button(**attrs, &)
end
private
def default_attrs
{
aria_label: "Toggle Sidebar",
title: "Toggle Sidebar",
tabindex: "-1",
class: [
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear",
"group-data-[side=left]:-right-4 group-data-[side=right]:left-0",
"after:absolute after:inset-y-0 after:start-1/2 after:w-[2px]",
"hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize",
"[[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0",
"group-data-[collapsible=offcanvas]:after:left-full",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2"
],
data: {
slot: "sidebar-rail",
sidebar: "rail",
action: "click->ruby-ui--sidebar#toggle"
}
}
end
end
end
25
Add RubyUI::SidebarSeparator to app/components/ruby_ui/sidebar/sidebar_separator.rb
# frozen_string_literal: true
module RubyUI
class SidebarSeparator < Base
def view_template(&)
Separator(**separator_attrs, &)
end
private
# slot! makes Separator replace its own data-slot="separator" instead of
# joining it. Added after Base's mix, which would strip the bang.
def separator_attrs
attrs.merge(data: attrs.fetch(:data, {}).merge(slot!: "sidebar-separator"))
end
def default_attrs
{
class: "mx-2 w-auto bg-sidebar-border",
data: {
sidebar: "separator"
}
}
end
end
end
26
Add RubyUI::SidebarTrigger to app/components/ruby_ui/sidebar/sidebar_trigger.rb
# frozen_string_literal: true
module RubyUI
class SidebarTrigger < Base
def view_template(&)
Button(variant: :ghost, size: :icon_sm, **trigger_attrs) do
panel_left_icon
span(class: "sr-only") { "Toggle Sidebar" }
end
end
private
# slot! makes Button replace its own data-slot="button" instead of joining
# it. Added after Base's mix, which would strip the bang.
def trigger_attrs
attrs.merge(data: attrs.fetch(:data, {}).merge(slot!: "sidebar-trigger"))
end
def default_attrs
{
data: {
sidebar: "trigger",
action: "click->ruby-ui--sidebar#toggle"
}
}
end
def panel_left_icon
svg(
xmlns: "http://www.w3.org/2000/svg",
width: "24",
height: "24",
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
stroke_width: "2",
stroke_linecap: "round",
stroke_linejoin: "round",
aria_hidden: "true",
class: "lucide lucide-panel-left"
) do |s|
s.rect(width: "18", height: "18", x: "3", y: "3", rx: "2")
s.path(d: "M9 3v18")
end
end
end
end
27
Add RubyUI::SidebarWrapper to app/components/ruby_ui/sidebar/sidebar_wrapper.rb
# frozen_string_literal: true
module RubyUI
class SidebarWrapper < Base
SIDEBAR_WIDTH = "16rem"
SIDEBAR_WIDTH_ICON = "3rem"
def view_template(&)
div(**attrs, &)
end
private
def default_attrs
{
class: "group/sidebar-wrapper flex min-h-svh w-full has-data-[variant=inset]:bg-sidebar",
style: "--sidebar-width: #{SIDEBAR_WIDTH}; --sidebar-width-icon: #{SIDEBAR_WIDTH_ICON};",
data: {
slot: "sidebar-wrapper",
controller: "ruby-ui--sidebar",
# Ctrl+B / Cmd+B toggles the sidebar, like shadcn's SidebarProvider.
action: "keydown.ctrl+b@window->ruby-ui--sidebar#toggle keydown.meta+b@window->ruby-ui--sidebar#toggle"
}
}
end
end
end
28
Add sidebar_controller.js to app/javascript/controllers/ruby_ui/sidebar_controller.js
import { Controller } from "@hotwired/stimulus";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const State = {
EXPANDED: "expanded",
COLLAPSED: "collapsed",
};
const MOBILE_BREAKPOINT = 768;
export default class extends Controller {
static targets = ["sidebar", "mobileSidebar"];
sidebarTargetConnected() {
const { state, collapsibleKind } = this.sidebarTarget.dataset;
this.open = state === State.EXPANDED;
this.collapsibleKind = collapsibleKind;
}
toggle(e) {
// A non-collapsible sidebar has neither target: leave the event (e.g.
// Ctrl+B in an editor) and the sidebar_state cookie alone.
if (this.#isMobile()) {
if (!this.hasMobileSidebarTarget) return;
e.preventDefault();
this.#openMobileSidebar();
return;
}
if (!this.hasSidebarTarget) return;
e.preventDefault();
this.open = !this.open;
this.onToggle();
}
onToggle() {
this.#updateSidebarState();
this.#persistSidebarState();
// shadcn's onOpenChange: listen with
// data-action="ruby-ui--sidebar:change->your-controller#method".
this.dispatch("change", { detail: { open: this.open } });
}
#updateSidebarState() {
if (!this.hasSidebarTarget) {
return;
}
const { dataset } = this.sidebarTarget;
dataset.state = this.open ? State.EXPANDED : State.COLLAPSED;
dataset.collapsible = this.open ? "" : this.collapsibleKind;
}
#persistSidebarState() {
document.cookie = `${SIDEBAR_COOKIE_NAME}=${this.open}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
}
#isMobile() {
return window.innerWidth < MOBILE_BREAKPOINT;
}
#openMobileSidebar() {
if (!this.hasMobileSidebarTarget) {
return;
}
this.mobileSidebarTarget.dispatchEvent(
new CustomEvent("ruby--ui-sidebar:open"),
);
}
}
29
Update the Stimulus controllers manifest file
rake stimulus:manifest:update30
Install required components
Component Sidebar relies on the following RubyUI components. Refer to their individual installation guides for setup instructions:
Components
| Component | Built using | Source |
|---|---|---|
CollapsibleSidebar | Phlex | |
MobileSidebar | Phlex | |
NonCollapsibleSidebar | Phlex | |
Sidebar | Phlex | |
SidebarContent | Phlex | |
SidebarDocs | Phlex | |
SidebarFooter | Phlex | |
SidebarGroup | Phlex | |
SidebarGroupAction | Phlex | |
SidebarGroupContent | Phlex | |
SidebarGroupLabel | Phlex | |
SidebarHeader | Phlex | |
SidebarInput | Phlex | |
SidebarInset | Phlex | |
SidebarMenu | Phlex | |
SidebarMenuAction | Phlex | |
SidebarMenuBadge | Phlex | |
SidebarMenuButton | Phlex | |
SidebarMenuItem | Phlex | |
SidebarMenuSkeleton | Phlex | |
SidebarMenuSub | Phlex | |
SidebarMenuSubButton | Phlex | |
SidebarMenuSubItem | Phlex | |
SidebarRail | Phlex | |
SidebarSeparator | Phlex | |
SidebarTrigger | Phlex | |
SidebarWrapper | Phlex | |
SidebarController | Stimulus JS |