Skip to main content

Tags

An interactive tag: a small control representing a discrete value, such as an email recipient or an active filter. A tag is visually related to a badge but is a distinct control: a badge is a static styled span, while a tag is a container whose segments (label, remove) are separately interactive. The container carries no padding of its own - each segment supplies its share, so every part of the tag is a hit target.

A tag takes the same subtle corner radius as a badge by default; pass rounded: true (or add l-ui-tag--rounded) for a pill shape, matching l-ui-badge--rounded.

Tags with remove

The l_ui_tag helper builds the tag from segments declared in order. t.text renders a static label; t.remove renders a trailing ✕ as a link (when given a URL) or a button. Use <% (not <%=) when calling builder methods - the builder captures their content. Give the remove segment an aria-label so it has an accessible name.

alice@example.com
bob@example.com
No remove
<%= l_ui_tag do |t| %>
  <% t.text "alice@example.com" %>
  <% t.remove recipient_path, aria: { label: "Remove alice@example.com" } %>
<% end %>

Rounded tags

Pass rounded: true for a pill shape. The segments' focus rings follow the tag's shape automatically.

alice@example.com
Rounded
<%= l_ui_tag(rounded: true) do |t| %>
  <% t.text "alice@example.com" %>
  <% t.remove recipient_path, aria: { label: "Remove alice@example.com" } %>
<% end %>

Tag rows

Wrap a row of tags - plus any trailing actions, such as a clear-all button - in l-ui-tag-row. It lays the row out with a gap and wraps onto multiple lines on narrow screens, making it the canonical container for a filter bar.

Status: Active
Author: Alice
Clear all
<div class="l-ui-tag-row">
  <%= l_ui_tag do |t| %>
    <% t.text "Status: Active" %>
    <% t.remove remove_filter_path, aria: { label: "Remove status filter" } %>
  <% end %>

  <%= link_to "Clear all", clear_filters_path,
        class: "l-ui-button l-ui-button--outline l-ui-button--small" %>
</div>

Truncation

A tag never grows past its container (max-width: 100%), and the helper wraps each label in l-ui-tag__label so a long label truncates with an ellipsis instead of overflowing - useful for tag rows on narrow screens. The example below constrains the row to force truncation.

a-very-long-address@example.com
<div class="l-ui-tag-row max-w-56">
  <%= l_ui_tag do |t| %>
    <% t.text "a-very-long-address@example.com" %>
    <% t.remove recipient_path, aria: { label: "Remove a-very-long-address@example.com" } %>
  <% end %>
</div>

Tags with a popover

A tag may carry a popover - e.g. a filter tag whose label opens its controls. Declare the label with t.button and the popover with t.popover (options match l_ui_popover). The whole tag becomes the placement target, so the popover aligns with the tag rather than the segment inside it, and button segments open it via popovertarget.

Filter controls go here - a form, a menu, or any other content.

<%= l_ui_tag do |t| %>
  <% t.button(aria: { label: "Edit status filter" }) do %>
    Status: Active
  <% end %>
  <% t.remove remove_filter_path, aria: { label: "Remove status filter" } %>
  <% t.popover do %>
    <div class="l-ui-popover__menu">
      <button type="button" class="l-ui-popover__menu-item">Active</button>
      <button type="button" class="l-ui-popover__menu-item">Archived</button>
    </div>
  <% end %>
<% end %>

CSS-classes example

Use the classes directly when you need full control over markup. Segments must be direct children of l-ui-tag, label before remove; when a remove segment follows, the label segment automatically gives up its right padding so the remove owns the gap. Wrap a label segment's content in l-ui-tag__label so it truncates with an ellipsis when the tag is squeezed.

carol@example.com
<div class="l-ui-tag">
  <span class="l-ui-tag__text">
    <span class="l-ui-tag__label">carol@example.com</span>
  </span>
  <button type="button" class="l-ui-tag__remove"
          aria-label="Remove carol@example.com">
    <svg class="l-ui-icon--sm" fill="currentColor"
         viewBox="0 0 24 24" aria-hidden="true">
      <path fill-rule="evenodd" clip-rule="evenodd" d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25Zm-1.72 6.97a.75.75 0 0 0-1.06 1.06L10.94 12l-1.72 1.72a.75.75 0 1 0 1.06 1.06L12 13.06l1.72 1.72a.75.75 0 1 0 1.06-1.06L13.06 12l1.72-1.72a.75.75 0 1 0-1.06-1.06L12 10.94l-1.72-1.72Z" />
    </svg>
  </button>
</div>

Helper options

l_ui_tag options
Option Description
rounded: Pill shape (matching l-ui-badge--rounded); defaults to false for the subtle badge shape
container: Extra HTML attributes for the wrapping <div> (e.g. class:)
t.text(content, **opts) Static label segment (<span>); pass content as the first argument or a block
t.button(**opts) Button segment; opens the tag's popover when one is declared
t.link(url, **opts) Link segment, styled like a button segment
t.remove(url = nil, **opts) Trailing remove segment: a link when url is given, otherwise a button. Renders a ✕ icon unless a block supplies custom content. Pass aria: { label: ... } for an accessible name
t.popover(**opts) Attaches a popover to the tag; the block is the popover body. Options (id:, placement:, align:, open:) match l_ui_popover

CSS classes

Tag CSS classes
Class Description
l-ui-tag-row Container for a row of tags plus any trailing actions (e.g. a filter bar); wraps onto multiple lines on narrow screens
l-ui-tag Tag container; carries the tag's colour and shape but no padding
l-ui-tag--rounded Modifier for a pill shape (matching l-ui-badge--rounded)
l-ui-tag__text Static label segment (apply to a <span>)
l-ui-tag__button Interactive label segment (apply to a <button> or <a>)
l-ui-tag__label Inner wrapper around a label segment's content (apply to a <span> inside l-ui-tag__text or l-ui-tag__button); truncates long labels with an ellipsis. The helper renders it for you
l-ui-tag__remove Trailing remove segment (apply to a <button> or <a>); pair with an icon such as l-ui-icon--sm