diff --git a/apps/nextjs/src/components/layout/header/configurable-header.module.css b/apps/nextjs/src/components/layout/header/configurable-header.module.css
index 9fd00d423..90d7ff292 100644
--- a/apps/nextjs/src/components/layout/header/configurable-header.module.css
+++ b/apps/nextjs/src/components/layout/header/configurable-header.module.css
@@ -96,14 +96,27 @@
.floatingControls {
position: fixed;
z-index: var(--homarr-z-index-board-header);
- inset-block-start: max(rem(10px), env(safe-area-inset-top));
- inset-inline-end: max(rem(10px), env(safe-area-inset-right));
+ top: max(rem(10px), env(safe-area-inset-top));
+ right: max(rem(10px), env(safe-area-inset-right));
padding: rem(8px);
border: rem(1px) solid color-mix(in srgb, var(--mantine-color-default-border) 70%, transparent);
border-radius: 999px;
background: color-mix(in srgb, var(--mantine-color-body) 58%, transparent);
box-shadow: 0 rem(12px) rem(36px) rgb(0 0 0 / 20%);
backdrop-filter: blur(rem(20px)) saturate(140%);
+ opacity: 0;
+ pointer-events: none;
+ transform: translateY(rem(-8px)) scale(0.98);
+ transition:
+ opacity 160ms ease,
+ transform 180ms ease;
+}
+
+.floatingControls[data-visible="true"],
+.floatingControls:focus-within {
+ opacity: 1;
+ pointer-events: auto;
+ transform: translateY(0) scale(1);
}
.floatingControls :global(.mantine-UnstyledButton-root) {
@@ -112,8 +125,18 @@
justify-content: center;
}
-:global([dir="rtl"]) .floatingControls {
- inset-inline-end: max(rem(10px), env(safe-area-inset-left));
+.floatingControlsCorner {
+ position: fixed;
+ z-index: calc(var(--homarr-z-index-board-header) - 1);
+ top: 0;
+ right: 0;
+ width: rem(112px);
+ height: rem(112px);
+ pointer-events: none;
+}
+
+.floatingControlsCorner[data-active="true"] {
+ pointer-events: auto;
}
@media (max-width: $mantine-breakpoint-sm) {
@@ -131,3 +154,9 @@
flex: 0 0 auto;
}
}
+
+@media (prefers-reduced-motion: reduce) {
+ .floatingControls {
+ transition: none;
+ }
+}
diff --git a/apps/nextjs/src/components/layout/header/configurable-header.tsx b/apps/nextjs/src/components/layout/header/configurable-header.tsx
index 807444e7c..b9946f087 100644
--- a/apps/nextjs/src/components/layout/header/configurable-header.tsx
+++ b/apps/nextjs/src/components/layout/header/configurable-header.tsx
@@ -1,7 +1,7 @@
"use client";
import type { ReactNode } from "react";
-import { useMemo } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import {
AppShellHeader,
Avatar,
@@ -57,6 +57,10 @@ interface ConfigurableHeaderProps {
}
type HeaderBoard = RouterOutputs["board"]["getAllBoards"][number];
+
+const floatingControlsIntroDurationMs = 5_000;
+const floatingControlsDismissDelayMs = 900;
+
export const ConfigurableHeader = ({
logo,
actions,
@@ -132,7 +136,7 @@ export const ConfigurableHeader = ({
) : (
-
+
{hasNavigation ? : null}
-
+
)}
>
@@ -150,6 +154,85 @@ export const ConfigurableHeader = ({
);
};
+const FloatingHeaderControls = ({ children }: { children: ReactNode }) => {
+ const [isIntroComplete, setIsIntroComplete] = useState(false);
+ const [isVisible, setIsVisible] = useState(true);
+ const isInteractingRef = useRef(false);
+ const dismissTimeoutRef = useRef(null);
+
+ const clearDismissTimeout = () => {
+ if (dismissTimeoutRef.current === null) return;
+ window.clearTimeout(dismissTimeoutRef.current);
+ dismissTimeoutRef.current = null;
+ };
+
+ const dismissSoon = () => {
+ if (!isIntroComplete) return;
+ clearDismissTimeout();
+ dismissTimeoutRef.current = window.setTimeout(() => {
+ if (!isInteractingRef.current) setIsVisible(false);
+ }, floatingControlsDismissDelayMs);
+ };
+
+ const revealFromCorner = () => {
+ if (!isIntroComplete) return;
+ clearDismissTimeout();
+ setIsVisible(true);
+ };
+
+ const startInteraction = () => {
+ isInteractingRef.current = true;
+ clearDismissTimeout();
+ setIsVisible(true);
+ };
+
+ const endInteraction = () => {
+ isInteractingRef.current = false;
+ dismissSoon();
+ };
+
+ useEffect(() => {
+ const introTimeout = window.setTimeout(() => {
+ setIsIntroComplete(true);
+ if (!isInteractingRef.current) setIsVisible(false);
+ }, floatingControlsIntroDurationMs);
+
+ return () => {
+ window.clearTimeout(introTimeout);
+ if (dismissTimeoutRef.current !== null) window.clearTimeout(dismissTimeoutRef.current);
+ };
+ }, []);
+
+ return (
+ <>
+
+ {
+ if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return;
+ endInteraction();
+ }}
+ >
+ {children}
+
+ >
+ );
+};
+
interface HeaderItemProps {
item: HeaderItem;
logo: ReactNode;