Placing the trigger
The built-in floating button is the default. ui.trigger controls it:
fab(default) — the package places the floating button itself.inline— no modal; the form is part of the page (a contact form, say).none— you place the trigger yourself.
ui.position decides which corner the built-in button sits in, and it takes exactly four
values: bottom-right (default), bottom-left, top-right, top-left. The value becomes a
class name, so a fifth spelling is not rejected — it simply matches no rule, and the button
keeps its position: fixed with no offsets and pins itself wherever it happened to sit in
the flow. It looks like a layout bug rather than a typo.
A mode mount prop overrides this per widget, so one page can carry an inline contact
form and a modal at the same time: <livewire:visual-feedback.report-widget mode="inline" />.
For your own trigger, use the component anywhere on the page:
<x-visual-feedback::trigger class="your-button-class">Report a problem</x-visual-feedback::trigger>
The trigger ships unstyled on purpose. In the plain view tree it renders a bare
<button class="visual-feedback-trigger"> and the bundled stylesheet carries no rule for
that class, so whatever you pass through class is the only styling it has — which is the
point: a trigger sitting in your navigation should wear your buttons, not this package's.
The class stays on the element as a stable hook, so you can target it as well.
Give it your own button styles, and if you have none to hand, give it at least 44×44 CSS px and a 16px font size: with browser defaults alone it renders around 21px tall, which is a hard target to hit on a phone. The WireKit tree needs none of this — its trigger is a WireKit button and inherits your design system's sizes.
If what you want is the floating button but somewhere else in the document — inside a layout that the widget's own position would fight, or on some pages only — the same component the package uses is public:
<x-visual-feedback::fab position="bottom-left" />
It takes position (the four values above) and label, and a slot overrides the label. Pair
it with ui.trigger=none, or you get two floating buttons: the built-in one renders whenever
the trigger is fab and the widget is in modal mode, and it does not know you placed
another.
Both components render nothing at all while the package is switched off, so a trigger you placed yourself disappears with the rest of it rather than opening an empty dialog — see Abuse protection.
Or open the widget from your own code — any element, any framework. The widget listens for one window event:
<button type="button" id="report-a-problem">Report a problem</button>
// resources/js/app.js, or any script your page already loads
document.getElementById('report-a-problem')?.addEventListener('click', () => {
window.dispatchEvent(new Event('visual-feedback:open'));
});
The handler is bound in a script rather than written as an onclick attribute, and that is
worth keeping. An inline event handler falls under script-src-attr, which falls back to
script-src — so under any policy without 'unsafe-inline' the attribute is dropped and the
button renders perfectly, looks right, and does nothing when clicked. The same failure as the
one the Integration contract describes for style attributes, one
level up and just as quiet. If your application has no content security policy the attribute
form works fine; this one works either way.
The inline form takes the same component with a mode:
@livewire('visual-feedback.report-widget', ['mode' => 'inline'])
Per-instance mount props
Six props override the configuration for one widget, so a documentation page and a
billing page in the same application can offer different forms. Every one of them is
#[Locked]: the browser can never change or widen them after mount, which is what makes
them safe to trust on the server.
| Prop | Type | Default | What it does |
|---|---|---|---|
mode | ?string | from ui.trigger | modal or inline. inline renders the form in the page with no modal |
categories | array | from categories | the category list this widget offers. Empty falls back to the configured list |
context | array | [] | context entries the host attaches to reports from this widget |
fields | array | from fields | per-field visibility, e.g. ['subject' => false]. A key here wins over the configuration; an absent key falls back to it |
recipient | ?string | from mail.to | where this widget's reports are mailed |
withScreenshot | ?bool | null | null follows the default (capture in modal mode only); false removes the capture from this widget, true adds it |
<livewire:visual-feedback.report-widget
mode="inline"
:categories="['bug', 'billing']"
:fields="['subject' => false]"
:context="[['key' => 'plan', 'label' => 'Plan', 'value' => $team->plan]]"
recipient="[email protected]"
:with-screenshot="false"
/>
The same names work through @livewire(), in their PHP spelling:
@livewire('visual-feedback.report-widget', [
'mode' => 'inline',
'categories' => ['bug', 'billing'],
'recipient' => '[email protected]',
'withScreenshot' => false,
])
Three details that are easy to get wrong:
- The prop is
categories, the property isavailableCategories. Mount takescategories; the component holds the resolved list under the longer name. - A context entry is a plain array with
key,labelandvalue, optionallyurlandidentifier. Entries whose values are not strings are dropped rather than rendered, so a typo here loses the entry silently. For context that should be on every report, use a context provider instead — see Configuration. withScreenshotmay narrow, never widen. Withscreenshot.strategyset tooffthe capture stays off everywhere, whatever a widget asks for.
recipient is visible in the page source
Livewire serializes a component's public properties into a wire:snapshot attribute on the
rendered element, and #[Locked] governs writes, not visibility. So a recipient you
mount is readable by anyone who views the source of that page, and by any crawler that
fetches it.
That is fine for an address you would print in a footer anyway. It is not fine for an internal team alias you would rather not publish. For that case, deliver through your own channel — see Delivery channels — and pick the address on the server, where the browser never sees it.