Skip to content
Brok UI

Loading…

No results

Reaction Bar

Open source

Row of actions for reacting to content: up/down vote, copy-to-clipboard, and share.

Version
v2.1.2
Stability
stable
License
MIT
Related
Action Bar
Badge
Toggle Group

Preview

previews.components.reaction-bar.default.blade.php 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>

Installation

terminal
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:

resources/js/ui/index.js JS
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.

  • blade resources/views/components/ui/reaction-bar.blade.php
  • js resources/js/ui/reaction-bar.js
Registry dependencies
None — installs on its own.
Packages
composer: jml/brok:^0.2
npm: 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.

reaction-bar.md
# 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

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
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.

reaction-bar

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

Status and lightweight feedback

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
root
Theming hooks
reaction-bar

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
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-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<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.

livewire-form.blade.php Blade
<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.

resources/views/components/ui/reaction-bar.blade.php Blade
@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>
resources/js/ui/reaction-bar.js JS
/**
 * 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