diff --git a/apps/docs/content/docs/developers/embedding/css-variables.mdx b/apps/docs/content/docs/developers/embedding/css-variables.mdx
index ae7185964..69836b737 100644
--- a/apps/docs/content/docs/developers/embedding/css-variables.mdx
+++ b/apps/docs/content/docs/developers/embedding/css-variables.mdx
@@ -134,6 +134,15 @@ Specific parts of the embed can be targeted with CSS classes for granular stylin
| `.embed--WaitingForTurn` | Waiting screen when it is not the user's turn |
| `.embed--DocumentCompleted` | Completion screen after signing |
| `.field--FieldRootContainer` | Base container for document fields |
+| `.field--FieldRootContainerHover` | Hover state for document fields |
+
+
+ Fields are rendered onto a canvas, which has no real `:hover` pseudo-class. To
+ style a field's hover state, target the `.field--FieldRootContainerHover` class
+ instead of `:hover`. The properties that take effect on hover are
+ `background-color`, `border-color`, `border-width`, `border-radius` and
+ `opacity`. Any of these you leave unset keep their resting value.
+
### Field Data Attributes
@@ -159,6 +168,12 @@ Fields expose data attributes for state-based styling:
opacity: 0.2;
}
+/* Style the field hover state */
+.field--FieldRootContainerHover {
+ background-color: rgba(0, 0, 0, 0.04);
+ border-color: var(--primary);
+}
+
/* Custom widget styling */
.embed--DocumentWidget {
background-color: #ffffff;
diff --git a/packages/lib/universal/field-renderer/field-canvas-style.ts b/packages/lib/universal/field-renderer/field-canvas-style.ts
index 316185607..612eab22c 100644
--- a/packages/lib/universal/field-renderer/field-canvas-style.ts
+++ b/packages/lib/universal/field-renderer/field-canvas-style.ts
@@ -1,10 +1,11 @@
import {
FIELD_PROBE_ANCHOR_SELECTOR,
+ FIELD_ROOT_CONTAINER_HOVER_CLASS_NAME,
FIELD_ROOT_CONTAINER_PROBE_CLASS_NAME,
} from '@documenso/ui/lib/field-root-container-classes';
import { colord } from 'colord';
-import type { FieldCanvasStyle, FieldRenderMode, FieldToRender } from './field-renderer';
+import type { FieldCanvasStyle, FieldCanvasStyleProps, FieldRenderMode, FieldToRender } from './field-renderer';
export type FieldCanvasStyleCache = Map;
@@ -98,6 +99,25 @@ const createFieldProbeElement = (field: FieldToRender): HTMLElement => {
return $probe;
};
+/**
+ * Map a probe's computed style onto the canvas-handled prop set. Shared by the
+ * resting and hover reads so both honor every property the renderer can apply
+ * (see `field-generic-items.ts`): fill, stroke, stroke width, corner radius and
+ * group opacity.
+ */
+const extractCanvasStyleProps = (computedStyle: CSSStyleDeclaration): FieldCanvasStyleProps => {
+ const borderWidth = getPixelValue(computedStyle.borderTopWidth);
+ const hasBorderStyle = computedStyle.borderTopStyle !== 'none' && Boolean(borderWidth);
+
+ return {
+ backgroundColor: getRenderableColor(computedStyle.backgroundColor),
+ borderColor: hasBorderStyle ? getRenderableColor(computedStyle.borderTopColor) : undefined,
+ borderRadius: getPixelValue(computedStyle.borderTopLeftRadius),
+ borderWidth: hasBorderStyle ? borderWidth : undefined,
+ opacity: getOpacityValue(computedStyle.opacity),
+ };
+};
+
const computeFieldCanvasStyleFromProbe = (field: FieldToRender): FieldCanvasStyle | undefined => {
if (typeof document === 'undefined' || typeof window === 'undefined') {
return undefined;
@@ -119,18 +139,17 @@ const computeFieldCanvasStyleFromProbe = (field: FieldToRender): FieldCanvasStyl
$anchor.appendChild($probe);
try {
- const computedStyle = window.getComputedStyle($probe);
- const borderWidth = getPixelValue(computedStyle.borderTopWidth);
- const borderColor = getRenderableColor(computedStyle.borderTopColor);
- const hasBorderStyle = computedStyle.borderTopStyle !== 'none' && Boolean(borderWidth);
- const borderRadius = getPixelValue(computedStyle.borderTopLeftRadius);
+ const restingStyle = extractCanvasStyleProps(window.getComputedStyle($probe));
+
+ // Reuse the same element for the hover read: adding the hover class makes
+ // `getComputedStyle` resolve the hovered cascade synchronously — no real
+ // pointer or `:hover` pseudo-class needed.
+ $probe.classList.add(FIELD_ROOT_CONTAINER_HOVER_CLASS_NAME);
+ const hoverStyle = extractCanvasStyleProps(window.getComputedStyle($probe));
return {
- backgroundColor: getRenderableColor(computedStyle.backgroundColor),
- borderColor: hasBorderStyle ? borderColor : undefined,
- borderRadius,
- borderWidth: hasBorderStyle ? borderWidth : undefined,
- opacity: getOpacityValue(computedStyle.opacity),
+ ...restingStyle,
+ hover: hoverStyle,
};
} finally {
$probe.remove();
diff --git a/packages/lib/universal/field-renderer/field-generic-items.ts b/packages/lib/universal/field-renderer/field-generic-items.ts
index d37d0234b..f8533add5 100644
--- a/packages/lib/universal/field-renderer/field-generic-items.ts
+++ b/packages/lib/universal/field-renderer/field-generic-items.ts
@@ -8,6 +8,11 @@ export const konvaTextFontFamily =
'"Noto Sans", "Noto Sans Japanese", "Noto Sans Chinese", "Noto Sans Korean", sans-serif';
export const konvaTextFill = 'black';
+// Renderer defaults, shared between `upsertFieldRect` and the hover interaction so
+// hover tweens return the field to its actual idle state rather than a guess.
+const DEFAULT_FIELD_STROKE_WIDTH = 2;
+const DEFAULT_FIELD_CORNER_RADIUS = 2;
+
export const upsertFieldGroup = (field: FieldToRender, options: RenderFieldElementOptions): Konva.Group => {
const { pageWidth, pageHeight, pageLayer, editable, scale } = options;
@@ -59,8 +64,8 @@ export const upsertFieldRect = (field: FieldToRender, options: RenderFieldElemen
height: fieldHeight,
fill: fieldCanvasStyle?.backgroundColor ?? DEFAULT_RECT_BACKGROUND,
stroke: fieldCanvasStyle?.borderColor ?? (color ? getRecipientColorStyles(color).baseRing : '#e5e7eb'),
- strokeWidth: fieldCanvasStyle?.borderWidth ?? 2,
- cornerRadius: fieldCanvasStyle?.borderRadius ?? 2,
+ strokeWidth: fieldCanvasStyle?.borderWidth ?? DEFAULT_FIELD_STROKE_WIDTH,
+ cornerRadius: fieldCanvasStyle?.borderRadius ?? DEFAULT_FIELD_CORNER_RADIUS,
strokeScaleEnabled: false,
visible: mode !== 'export',
} satisfies Partial);
@@ -119,71 +124,92 @@ type CreateFieldHoverInteractionOptions = {
fieldRect: Konva.Rect;
};
+type FieldHoverTweenState = {
+ fill: string;
+ stroke: string;
+ strokeWidth: number;
+ cornerRadius: number;
+ opacity: number;
+};
+
/**
* Adds smooth transition-like behavior for hover effects to the field group and rectangle.
+ *
+ * Resting and hover states both derive from the resolved field canvas style (read
+ * from a probe, so custom embed CSS is honored — hover via the
+ * `field--FieldRootContainerHover` class). Every canvas-handled property is
+ * respected on hover: fill, stroke, stroke width, corner radius and opacity. When
+ * the embedder hasn't customized a hover property (its probed value matches the
+ * resting value), it falls back to the resting value — except the background,
+ * which falls back to the default recipient hover color to preserve the built-in
+ * hover effect for teams without custom branding.
*/
export const createFieldHoverInteraction = ({ options, fieldGroup, fieldRect }: CreateFieldHoverInteractionOptions) => {
- const { mode } = options;
+ const { mode, color, fieldCanvasStyle } = options;
- if (mode === 'export' || !options.color) {
+ if (mode === 'export' || !color) {
return;
}
- if (options.fieldCanvasStyle?.backgroundColor) {
- return;
- }
+ const recipientStyles = getRecipientColorStyles(color);
- const hoverColor = getRecipientColorStyles(options.color).baseRingHover;
+ const restingState: FieldHoverTweenState = {
+ fill: fieldCanvasStyle?.backgroundColor ?? DEFAULT_RECT_BACKGROUND,
+ stroke: fieldCanvasStyle?.borderColor ?? recipientStyles.baseRing,
+ strokeWidth: fieldCanvasStyle?.borderWidth ?? DEFAULT_FIELD_STROKE_WIDTH,
+ cornerRadius: fieldCanvasStyle?.borderRadius ?? DEFAULT_FIELD_CORNER_RADIUS,
+ opacity: fieldCanvasStyle?.opacity ?? 1,
+ };
- fieldGroup.on('mouseover', () => {
- const layer = fieldRect.getLayer();
- if (!layer) {
+ const hover = fieldCanvasStyle?.hover;
+
+ // A hover prop is "customized" only when it differs from the resting value;
+ // otherwise the hover class resolved to nothing and we keep the resting value.
+ const pickHover = (hoverValue: T | undefined, restingValue: T, customizedFallback: T): T => {
+ if (hoverValue === undefined || hoverValue === restingValue) {
+ return customizedFallback;
+ }
+
+ return hoverValue;
+ };
+
+ const hoverState: FieldHoverTweenState = {
+ // Background is special: when uncustomized, fall back to the recipient hover
+ // color rather than the resting fill so the default hover effect still shows.
+ fill: pickHover(hover?.backgroundColor, restingState.fill, recipientStyles.baseRingHover),
+ stroke: pickHover(hover?.borderColor, restingState.stroke, restingState.stroke),
+ strokeWidth: pickHover(hover?.borderWidth, restingState.strokeWidth, restingState.strokeWidth),
+ cornerRadius: pickHover(hover?.borderRadius, restingState.cornerRadius, restingState.cornerRadius),
+ opacity: pickHover(hover?.opacity, restingState.opacity, restingState.opacity),
+ };
+
+ const tweenTo = (state: FieldHoverTweenState) => {
+ if (!fieldRect.getLayer()) {
return;
}
new Konva.Tween({
node: fieldRect,
duration: 0.3,
- fill: hoverColor,
+ fill: state.fill,
+ stroke: state.stroke,
+ strokeWidth: state.strokeWidth,
+ cornerRadius: state.cornerRadius,
}).play();
- });
-
- fieldGroup.on('mouseout', () => {
- const layer = fieldRect.getLayer();
- if (!layer) {
- return;
- }
new Konva.Tween({
- node: fieldRect,
+ node: fieldGroup,
duration: 0.3,
- fill: DEFAULT_RECT_BACKGROUND,
+ opacity: state.opacity,
}).play();
- });
+ };
- fieldGroup.on('transformstart', () => {
- const layer = fieldRect.getLayer();
- if (!layer) {
- return;
- }
+ // Field groups are reused across re-renders (upserted via `findOne`), so clear
+ // any previously-bound hover listeners before re-registering to avoid stacking.
+ fieldGroup.off('mouseover.fieldHover mouseout.fieldHover transformstart.fieldHover transformend.fieldHover');
- new Konva.Tween({
- node: fieldRect,
- duration: 0.3,
- fill: hoverColor,
- }).play();
- });
-
- fieldGroup.on('transformend', () => {
- const layer = fieldRect.getLayer();
- if (!layer) {
- return;
- }
-
- new Konva.Tween({
- node: fieldRect,
- duration: 0.3,
- fill: DEFAULT_RECT_BACKGROUND,
- }).play();
- });
+ fieldGroup.on('mouseover.fieldHover', () => tweenTo(hoverState));
+ fieldGroup.on('mouseout.fieldHover', () => tweenTo(restingState));
+ fieldGroup.on('transformstart.fieldHover', () => tweenTo(hoverState));
+ fieldGroup.on('transformend.fieldHover', () => tweenTo(restingState));
};
diff --git a/packages/lib/universal/field-renderer/field-renderer.ts b/packages/lib/universal/field-renderer/field-renderer.ts
index 5b8169e8c..73bb573a5 100644
--- a/packages/lib/universal/field-renderer/field-renderer.ts
+++ b/packages/lib/universal/field-renderer/field-renderer.ts
@@ -44,7 +44,7 @@ export type RenderFieldElementOptions = {
translations: Record | null;
};
-export type FieldCanvasStyle = {
+export type FieldCanvasStyleProps = {
backgroundColor?: string;
borderColor?: string;
borderRadius?: number;
@@ -52,6 +52,16 @@ export type FieldCanvasStyle = {
opacity?: number;
};
+export type FieldCanvasStyle = FieldCanvasStyleProps & {
+ /**
+ * The custom hover state, resolved from the `field--FieldRootContainerHover`
+ * class. Mirrors the resting prop set so any canvas-handled property can change
+ * on hover. Properties left unset fall back to the resting value (and the
+ * background falls back to the default recipient hover color).
+ */
+ hover?: FieldCanvasStyleProps;
+};
+
/**
* Converts a fields percentage based values to pixel based values.
*/
diff --git a/packages/ui/lib/field-root-container-classes.ts b/packages/ui/lib/field-root-container-classes.ts
index 8eabc695f..e4e6bf8d2 100644
--- a/packages/ui/lib/field-root-container-classes.ts
+++ b/packages/ui/lib/field-root-container-classes.ts
@@ -5,6 +5,18 @@ export const FIELD_ROOT_CONTAINER_CLASS_NAME = `${FIELD_ROOT_CONTAINER_SHARED_CL
export const FIELD_ROOT_CONTAINER_PROBE_CLASS_NAME = `field--FieldRootContainerProbe ${FIELD_ROOT_CONTAINER_SHARED_CLASS_NAME}`;
+/**
+ * Class embedders target to style a field's hover state, e.g.
+ * `.field--FieldRootContainerHover { background-color: ... }`.
+ *
+ * Canvas-rendered fields can't use the `:hover` pseudo-class (there's no real
+ * DOM element to hover), so the hover probe applies this class permanently to
+ * read the hovered cascade synchronously via `getComputedStyle`. Using a real
+ * class instead of `:hover` keeps resolution deterministic — no stylesheet
+ * parsing, pseudo-class synthesis, or source-order/specificity guesswork.
+ */
+export const FIELD_ROOT_CONTAINER_HOVER_CLASS_NAME = 'field--FieldRootContainerHover';
+
/**
* Selector for the element the probe is appended into when reading computed
* field styles. It must be an ancestor of where real fields render so the probe