Reaction Bar
Row of actions for reacting to content: up/down vote, copy-to-clipboard, and share.
Preview
{{-- neutral: the pressed vote fills its glyph on an accent chip; colored: green up, red down. --}}
<div class="flex w-full flex-col items-center gap-4">
<x-ui.reaction-bar text="{{ __('Copied text') }}" />
<x-ui.reaction-bar tone="colored" text="{{ __('Copied text') }}" />
</div>
Installation
php artisan ui:add reaction-bar
Note
This component ships an Alpine behavior module at
resources/js/ui/reaction-bar.js. Import it once from your bundle so it registers on alpine:init:
import './reaction-bar.js';
Registry contract
php artisan ui:add reaction-bar
writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.
-
resources/views/components/ui/reaction-bar.blade.php -
resources/js/ui/reaction-bar.js
- Registry dependencies
- None — installs on its own.
- Packages
-
composer: jml/brok:^0.2npm: alpinejs
Use with AI
A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.
# Brok UI: Reaction Bar (`reaction-bar`)
Row of actions for reacting to content: up/down vote, copy-to-clipboard, and share.
Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.
## Install
```bash
php artisan ui:add reaction-bar
```
## Usage
```blade
{{-- neutral: the pressed vote fills its glyph on an accent chip; colored: green up, red down. --}}
<div class="flex w-full flex-col items-center gap-4">
<x-ui.reaction-bar text="{{ __('Copied text') }}" />
<x-ui.reaction-bar tone="colored" text="{{ __('Copied text') }}" />
</div>
```
## Props
- `text` (string, default ``) — Text copied to the clipboard by the copy button.
- `tone` (string, default `neutral`) — Pressed-vote colour: neutral (accent chip) or colored (green up, red down).
## Use when
- Use to show state, status, notification, or lightweight social feedback near the relevant object.
- Adding upvote/downvote, copy-to-clipboard, and share actions to a piece of content such as an article, comment, or AI response.
- Needing a compact, mutually exclusive vote toggle that visibly fills the pressed glyph.
## Avoid when
- Do not overload small status elements with explanations that should remain visible in text.
- Needing a numeric like or reaction count display; use badge or stat instead, since this bar only tracks the pressed state, not a count.
- Needing an emoji-style multi-reaction picker; this bar is limited to up/down vote, copy, and share.
## Anti-patterns
- Using color or icon alone to communicate state
## Rules
- Use the `<brok:reaction-bar>` tag (or `<x-ui.reaction-bar>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.
## Links
- Docs: https://brokui.dev/docs/components/reaction-bar
- Registry JSON (files, props, contract): https://brokui.dev/r/open/reaction-bar.json
Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.
API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| text | string | Text copied to the clipboard by the copy button. | |
| tone | string | neutral | Pressed-vote colour: neutral (accent chip) or colored (green up, red down). |
Slots
default— Unused; the component has no visible default slot content.
Data slots
Stable hooks for CSS overrides and browser tests.
Behavior
- Upvote and downvote are mutually exclusive; pressing one clears the other.
- Copy writes text to the clipboard and flips to a transient check icon and "Copied" label before reverting.
- The share button does not open a native share sheet itself; it dispatches a reaction-bar-share event for the host to handle.
- Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
- Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.
Guidance
Communicate concise object or system state.
Use when
- Use to show state, status, notification, or lightweight social feedback near the relevant object.
- Adding upvote/downvote, copy-to-clipboard, and share actions to a piece of content such as an article, comment, or AI response.
- Needing a compact, mutually exclusive vote toggle that visibly fills the pressed glyph.
Avoid when
- Do not overload small status elements with explanations that should remain visible in text.
- Needing a numeric like or reaction count display; use badge or stat instead, since this bar only tracks the pressed state, not a count.
- Needing an emoji-style multi-reaction picker; this bar is limited to up/down vote, copy, and share.
Use instead
- Persistent explanatory text
Anti-patterns
- Using color or icon alone to communicate state
- Anatomy
- Theming hooks
Accessibility
- Keyboard
- native
- Focus
native
- The root carries role="group" with an aria-label of "Reactions"; each vote button reflects its pressed state via aria-pressed.
- The copy button's aria-label and title switch between "Copy" and "Copied" so screen reader and pointer users get the same feedback.
- Semantic HTML and a stable
data-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, so keyboard focus is always visible. - Disabled and invalid states are conveyed to assistive tech, not by color alone.
- Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
- Labels go through
__()and layout uses logical properties (ms-*,text-start), so it mirrors underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<div wire:key="reaction-bar-{{ $record->id }}">
{{-- neutral: the pressed vote fills its glyph on an accent chip; colored: green up, red down. --}}
<div class="flex w-full flex-col items-center gap-4">
<x-ui.reaction-bar text="{{ __('Copied text') }}" />
<x-ui.reaction-bar tone="colored" text="{{ __('Copied text') }}" />
</div>
</div>
Validation
Validation support: native. Keep the error message connected with aria-describedby.
<form wire:submit="save" class="space-y-2">
<brok:reaction-bar
wire:model="value"
:aria-invalid="$errors->has('value') ? 'true' : 'false'"
aria-describedby="value-error"
/>
@error('value')
<p id="value-error" role="alert">{{ $message }}</p>
@enderror
<brok:button type="submit" wire:loading.attr="disabled">
<span wire:loading.remove>Save</span>
<span wire:loading>Saving…</span>
</brok:button>
</form>
Source
The exact, editable files ui:add writes
into your app. Previews render this same code; there are no preview-only components.
@props([
'text' => '',
// neutral (default): the pressed vote fills its glyph on an accent chip.
// colored: thumbs-up presses green, thumbs-down presses red.
'tone' => 'neutral',
])
{{--
Reaction bar — a row of actions for reacting to content. The thumbs-up and
thumbs-down votes are mutually exclusive (setVote toggles); the pressed vote
fills its glyph on an accent chip (aria-pressed drives the styling — primary
is near-black here, so a colour change alone was invisible). Copy writes the text to the
clipboard and flips to a transient check + "Copied" label, then restores. The
share button emits a `reaction-bar-share` event for the host to handle.
--}}
@php
$tone = $tone === 'colored' ? 'colored' : 'neutral';
$upPressed = $tone === 'colored'
? 'aria-pressed:bg-success-soft aria-pressed:text-success-text aria-pressed:[&_svg]:fill-current'
: 'aria-pressed:bg-accent aria-pressed:text-foreground aria-pressed:[&_svg]:fill-current';
$downPressed = $tone === 'colored'
? 'aria-pressed:bg-destructive-soft aria-pressed:text-destructive-text aria-pressed:[&_svg]:fill-current'
: 'aria-pressed:bg-accent aria-pressed:text-foreground aria-pressed:[&_svg]:fill-current';
@endphp
<div
x-data="uiReactionBar({ text: @js($text) })"
data-slot="reaction-bar"
data-tone="{{ $tone }}"
role="group"
aria-label="{{ __('Reactions') }}"
{{ $attributes->merge(['class' => 'inline-flex items-center gap-1 text-muted-foreground [&_svg]:size-4']) }}
>
<button
type="button"
@click="setVote('up')"
:aria-pressed="vote === 'up' ? 'true' : 'false'"
aria-label="{{ __('Upvote') }}"
title="{{ __('Upvote') }}"
class="inline-flex size-8 items-center justify-center rounded-md transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring {{ $upPressed }} motion-reduce:transition-none"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 10v12"/><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"/></svg>
</button>
<button
type="button"
@click="setVote('down')"
:aria-pressed="vote === 'down' ? 'true' : 'false'"
aria-label="{{ __('Downvote') }}"
title="{{ __('Downvote') }}"
class="inline-flex size-8 items-center justify-center rounded-md transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring {{ $downPressed }} motion-reduce:transition-none"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 14V2"/><path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z"/></svg>
</button>
<span class="mx-1 h-4 w-px bg-border" aria-hidden="true"></span>
<button
type="button"
@click="copy()"
:aria-label="copied ? '{{ __('Copied') }}' : '{{ __('Copy') }}'"
:title="copied ? '{{ __('Copied') }}' : '{{ __('Copy') }}'"
class="inline-flex h-8 items-center justify-center gap-2 rounded-md px-2 transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none"
>
<svg x-show="!copied" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>
<svg x-show="copied" x-cloak class="text-success" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>
<span x-show="copied" x-cloak class="text-xs text-success">{{ __('Copied') }}</span>
</button>
<button
type="button"
@click="share()"
aria-label="{{ __('Share') }}"
title="{{ __('Share') }}"
class="inline-flex size-8 items-center justify-center rounded-md transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring motion-reduce:transition-none"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" x2="15.42" y1="13.51" y2="17.49"/><line x1="15.41" x2="8.59" y1="6.51" y2="10.49"/></svg>
</button>
</div>
/**
* Reaction bar behavior.
*
* Tracks a single vote ('up' | 'down' | null); setVote toggles the same value
* off and switches between the two, then dispatches `reaction-bar-vote`. copy()
* writes the text to the clipboard, shows a transient "Copied" state for ~1.6s,
* then restores. share() prefers the native Web Share sheet and falls back to
* dispatching `reaction-bar-share` for the host to handle.
*/
document.addEventListener('alpine:init', () => {
window.Alpine.data('uiReactionBar', (cfg = {}) => ({
text: cfg.text ?? '',
vote: null,
copied: false,
_timer: null,
setVote(value) {
this.vote = this.vote === value ? null : value;
this.$el.dispatchEvent(
new CustomEvent('reaction-bar-vote', {
detail: { vote: this.vote },
bubbles: true,
})
);
},
async copy() {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(this.text ?? '');
}
} catch (error) {
// Clipboard may be blocked (insecure context / permissions);
// still surface the confirmation so the action feels complete.
}
this.copied = true;
clearTimeout(this._timer);
this._timer = setTimeout(() => {
this.copied = false;
}, 1600);
},
async share() {
const payload = { text: this.text ?? '' };
try {
if (navigator.share) {
await navigator.share(payload);
return;
}
} catch (error) {
// User dismissed the share sheet or it is unavailable — fall through.
}
this.$el.dispatchEvent(
new CustomEvent('reaction-bar-share', {
detail: payload,
bubbles: true,
})
);
},
destroy() {
clearTimeout(this._timer);
},
}));
});
Ownership & lifecycle
Owner, release state, review evidence and adoption for this item.
- Owner
- Platform UI (@JoshJML)
- Current version
-
2.1.2 - Status
- Stable
- License
-
open - Accessibility reviewed
- No review date recorded
- Last breaking change
- No date recorded
- Deprecation
- Not deprecated
- Contract
-
v6 - Foundation
-
≥ 1.0.0