feat: add basic glassmorphism

This commit is contained in:
2026-07-01 18:30:14 +00:00
parent 1a928923c4
commit 76f56c1571
7 changed files with 462 additions and 110 deletions
+3 -3
View File
@@ -100,14 +100,14 @@ const classes = {
kcBodyClass: "", kcBodyClass: "",
kcLoginClass: kcLoginClass:
"relative z-[1] min-h-screen flex flex-col items-center pt-[15vh] px-4 pb-8 sm:px-[18px] mac-slide-up", "relative z-[1] min-h-screen flex flex-col items-center justify-center px-4 pb-8 sm:px-[18px] mac-slide-up",
kcHeaderWrapperClass: "max-w-[600px] mx-auto", kcHeaderWrapperClass: "max-w-[600px] mx-auto",
kcHeaderClass: kcHeaderClass:
"mb-6 text-center text-[0.93rem] tracking-[0.01em] text-muted-foreground", "mb-6 text-center text-[0.93rem] tracking-[0.01em] text-foreground/90 glass-text-stable",
kcFormCardClass: kcFormCardClass:
"relative w-full sm:w-[460px] rounded-xl border border-border bg-card px-4 py-5 sm:px-7 sm:py-[30px]", "glass relative w-full sm:w-[460px] rounded-xl px-4 py-5 sm:px-7 sm:py-[30px]",
kcFormHeaderClass: "relative grid grid-cols-[1fr_auto] gap-3 items-baseline mb-1", kcFormHeaderClass: "relative grid grid-cols-[1fr_auto] gap-3 items-baseline mb-1",
kcLocaleMainClass: "m-0 col-start-2 row-start-1 flex items-center", kcLocaleMainClass: "m-0 col-start-2 row-start-1 flex items-center",
+103 -100
View File
@@ -5,7 +5,8 @@ import { getKcClsx } from "keycloakify/login/lib/kcClsx";
import { useSetClassName } from "keycloakify/tools/useSetClassName"; import { useSetClassName } from "keycloakify/tools/useSetClassName";
import { useInitialize } from "keycloakify/login/Template.useInitialize"; import { useInitialize } from "keycloakify/login/Template.useInitialize";
import type { TemplateProps } from "keycloakify/login/TemplateProps"; import type { TemplateProps } from "keycloakify/login/TemplateProps";
import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"; import { CardContent, CardFooter, CardHeader } from "@/components/ui/card";
import { GlassAmbientBackground, GlassCard } from "./glass";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import type { KcContext } from "./KcContext"; import type { KcContext } from "./KcContext";
import type { I18n } from "./i18n"; import type { I18n } from "./i18n";
@@ -86,106 +87,108 @@ export default function Template(props: TemplateProps<KcContext, I18n>) {
})(); })();
return ( return (
<div className={kcClsx("kcLoginClass")}> <GlassAmbientBackground>
<div id="kc-header" className={kcClsx("kcHeaderClass")}> <div className={kcClsx("kcLoginClass")}>
<div id="kc-header-wrapper" className={kcClsx("kcHeaderWrapperClass")}> <div id="kc-header" className={kcClsx("kcHeaderClass")}>
{msg("loginTitleHtml", realm.displayNameHtml || realm.name)} <div id="kc-header-wrapper" className={kcClsx("kcHeaderWrapperClass")}>
</div> {msg("loginTitleHtml", realm.displayNameHtml || realm.name)}
</div>
<Card className={clsx(kcClsx("kcFormCardClass"), "w-full sm:w-[460px]")}>
<CardHeader className={clsx(kcClsx("kcFormHeaderClass"), "space-y-0 p-0 pb-4")}>
{enabledLanguages.length > 1 && (
<div className={kcClsx("kcLocaleMainClass")} id="kc-locale">
<div id="kc-locale-wrapper" className={kcClsx("kcLocaleWrapperClass")}>
<div id="kc-locale-dropdown" className={clsx("menu-button-links", kcClsx("kcLocaleDropDownClass"))}>
<button
tabIndex={1}
id="kc-current-locale-link"
aria-label={msgStr("languages")}
aria-haspopup="true"
aria-expanded="false"
aria-controls="language-switch1"
>
{currentLanguage.label}
</button>
<ul
role="menu"
tabIndex={-1}
aria-labelledby="kc-current-locale-link"
aria-activedescendant=""
id="language-switch1"
className={kcClsx("kcLocaleListClass")}
>
{enabledLanguages.map(({ languageTag, label, href }, i: number) => (
<li key={languageTag} className={kcClsx("kcLocaleListItemClass")} role="none">
<a role="menuitem" id={`language-${i + 1}`} className={kcClsx("kcLocaleItemClass")} href={href}>
{label}
</a>
</li>
))}
</ul>
</div>
</div>
</div>
)}
{header}
</CardHeader>
<CardContent id="kc-content" className="p-0">
<div id="kc-content-wrapper">
{displayMessage && message !== undefined && (message.type !== "warning" || !isAppInitiatedAction) && (
<Alert
className={clsx(
`alert-${message.type}`,
kcClsx("kcAlertClass"),
`pf-m-${message.type === "error" ? "danger" : message.type}`,
"mb-4"
)}
>
<AlertDescription
dangerouslySetInnerHTML={{
__html: kcSanitize(message.summary)
}}
/>
</Alert>
)}
{children}
{auth !== undefined && auth.showTryAnotherWayLink && (
<form id="kc-select-try-another-way-form" action={url.loginAction} method="post">
<div className={kcClsx("kcFormGroupClass")}>
<input type="hidden" name="tryAnotherWay" value="on" />
<a
href="#"
id="try-another-way"
onClick={event => {
(document.forms.namedItem("kc-select-try-another-way-form") as HTMLFormElement).requestSubmit();
event.preventDefault();
return false;
}}
>
{msg("doTryAnotherWay")}
</a>
</div>
</form>
)}
{socialProvidersNode}
{displayInfo && (
<div id="kc-info" className={kcClsx("kcSignUpClass")}>
<div id="kc-info-wrapper" className={kcClsx("kcInfoAreaWrapperClass")}>
{infoNode}
</div>
</div>
)}
</div> </div>
</CardContent> </div>
{infoNode && !displayInfo && <CardFooter className="p-0 pt-4">{infoNode}</CardFooter>} <GlassCard className={clsx(kcClsx("kcFormCardClass"), "w-full sm:w-[460px]")}>
</Card> <CardHeader className={clsx(kcClsx("kcFormHeaderClass"), "space-y-0 p-0 pb-4")}>
</div> {enabledLanguages.length > 1 && (
<div className={kcClsx("kcLocaleMainClass")} id="kc-locale">
<div id="kc-locale-wrapper" className={kcClsx("kcLocaleWrapperClass")}>
<div id="kc-locale-dropdown" className={clsx("menu-button-links", kcClsx("kcLocaleDropDownClass"))}>
<button
tabIndex={1}
id="kc-current-locale-link"
aria-label={msgStr("languages")}
aria-haspopup="true"
aria-expanded="false"
aria-controls="language-switch1"
>
{currentLanguage.label}
</button>
<ul
role="menu"
tabIndex={-1}
aria-labelledby="kc-current-locale-link"
aria-activedescendant=""
id="language-switch1"
className={kcClsx("kcLocaleListClass")}
>
{enabledLanguages.map(({ languageTag, label, href }, i: number) => (
<li key={languageTag} className={kcClsx("kcLocaleListItemClass")} role="none">
<a role="menuitem" id={`language-${i + 1}`} className={kcClsx("kcLocaleItemClass")} href={href}>
{label}
</a>
</li>
))}
</ul>
</div>
</div>
</div>
)}
{header}
</CardHeader>
<CardContent id="kc-content" className="p-0">
<div id="kc-content-wrapper">
{displayMessage && message !== undefined && (message.type !== "warning" || !isAppInitiatedAction) && (
<Alert
className={clsx(
`alert-${message.type}`,
kcClsx("kcAlertClass"),
`pf-m-${message.type === "error" ? "danger" : message.type}`,
"mb-4"
)}
>
<AlertDescription
dangerouslySetInnerHTML={{
__html: kcSanitize(message.summary)
}}
/>
</Alert>
)}
{children}
{auth !== undefined && auth.showTryAnotherWayLink && (
<form id="kc-select-try-another-way-form" action={url.loginAction} method="post">
<div className={kcClsx("kcFormGroupClass")}>
<input type="hidden" name="tryAnotherWay" value="on" />
<a
href="#"
id="try-another-way"
onClick={event => {
(document.forms.namedItem("kc-select-try-another-way-form") as HTMLFormElement).requestSubmit();
event.preventDefault();
return false;
}}
>
{msg("doTryAnotherWay")}
</a>
</div>
</form>
)}
{socialProvidersNode}
{displayInfo && (
<div id="kc-info" className={kcClsx("kcSignUpClass")}>
<div id="kc-info-wrapper" className={kcClsx("kcInfoAreaWrapperClass")}>
{infoNode}
</div>
</div>
)}
</div>
</CardContent>
{infoNode && !displayInfo && <CardFooter className="p-0 pt-4">{infoNode}</CardFooter>}
</GlassCard>
</div>
</GlassAmbientBackground>
); );
} }
@@ -0,0 +1,71 @@
import * as React from "react";
/**
* Animated ambient background for the glassmorphism login theme.
* Uses only CSS transforms/opacity for GPU-composited motion.
* Respects `prefers-reduced-motion` (animation stops) and is aria-hidden.
*/
export function GlassAmbientBackground({ children }: { children: React.ReactNode }) {
return (
<>
<div
aria-hidden="true"
className="fixed inset-0 -z-10 overflow-hidden pointer-events-none"
>
{/* Slow, soft color blobs */}
<div
className="glass-ambient-blob"
style={{
width: "52vw",
height: "52vw",
left: "-12vw",
top: "-12vh",
background: "var(--glass-ambient-1)",
animationDuration: "24s",
animationDelay: "-5s"
}}
/>
<div
className="glass-ambient-blob"
style={{
width: "44vw",
height: "44vw",
right: "-8vw",
top: "8vh",
background: "var(--glass-ambient-2)",
animationDuration: "28s",
animationDelay: "-12s"
}}
/>
<div
className="glass-ambient-blob"
style={{
width: "38vw",
height: "38vw",
left: "18vw",
bottom: "-12vh",
background: "var(--glass-ambient-3)",
animationDuration: "32s",
animationDelay: "-8s"
}}
/>
<div
className="glass-ambient-blob"
style={{
width: "34vw",
height: "34vw",
right: "14vw",
bottom: "4vh",
background: "var(--glass-ambient-4)",
animationDuration: "26s",
animationDelay: "-18s"
}}
/>
{/* Sharper mesh layer so the glass has defined shapes to refract */}
<div className="glass-ambient-mesh" />
</div>
{children}
</>
);
}
+26
View File
@@ -0,0 +1,26 @@
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* Glass card wrapper.
* Applies the base `.glass` utility and keeps the same flex/gap contract
* as the shadcn Card so upstream changes around the Card component are easy
* to merge: only this isolated component needs to be updated.
*/
export function GlassCard({
className,
children,
...props
}: React.ComponentProps<"div">) {
return (
<div
className={cn(
"glass relative flex flex-col gap-6 p-6 sm:p-8 text-card-foreground",
className
)}
{...props}
>
{children}
</div>
);
}
+209
View File
@@ -0,0 +1,209 @@
/**
* Glassmorphism / Liquid Glass theme tokens
*
* Isolated module: all glass-specific tokens and utilities live here.
* Changing upstream Keycloakify or shadcn files should not affect this file,
* and changes here are intentionally scoped to the login theme.
*/
:root {
/* Blur radius — keep between 1020px for GPU/UX balance */
--glass-blur: 14px;
--glass-blur-heavy: 20px;
/*
* Light theme glass tokens.
* Higher opacity than typical Dribbble glass so the card is readable
* and clearly separated from the animated background.
*/
--glass-bg: hsla(0 0% 100% / 0.42);
--glass-bg-solid: hsla(0 0% 100% / 0.82);
--glass-bg-scrim: hsla(0 0% 100% / 0.62);
--glass-border: hsla(0 0% 100% / 0.58);
--glass-border-highlight: hsla(0 0% 100% / 0.85);
--glass-shadow: 0 14px 44px hsla(220 40% 16% / 0.18);
--glass-shadow-elevated: 0 24px 64px hsla(220 40% 12% / 0.24);
/* Ambient background blobs — saturated and fairly opaque so the glass has something to refract */
--glass-ambient-1: hsla(250 85% 62% / 0.55);
--glass-ambient-2: hsla(320 80% 58% / 0.5);
--glass-ambient-3: hsla(190 90% 52% / 0.48);
--glass-ambient-4: hsla(40 95% 58% / 0.42);
/* Text stability on busy backgrounds */
--glass-text-shadow: 0 1px 2px hsla(0 0% 0% / 0.1);
}
@media (prefers-color-scheme: dark) {
:root {
/* Dark glass needs to be noticeably darker/tinted or it disappears on a dark page */
--glass-bg: hsla(220 20% 14% / 0.62);
--glass-bg-solid: hsla(220 20% 10% / 0.9);
--glass-bg-scrim: hsla(220 20% 8% / 0.78);
--glass-border: hsla(0 0% 100% / 0.18);
--glass-border-highlight: hsla(0 0% 100% / 0.32);
--glass-shadow: 0 14px 44px hsla(0 0% 0% / 0.45);
--glass-shadow-elevated: 0 24px 64px hsla(0 0% 0% / 0.55);
/* Dark-mode blobs: still vivid but not overwhelming */
--glass-ambient-1: hsla(250 85% 58% / 0.4);
--glass-ambient-2: hsla(320 80% 55% / 0.36);
--glass-ambient-3: hsla(190 90% 50% / 0.36);
--glass-ambient-4: hsla(40 95% 55% / 0.3);
--glass-text-shadow: 0 1px 3px hsla(0 0% 0% / 0.4);
}
}
/* Reduced transparency: make glass almost opaque and drop the blur */
@media (prefers-reduced-transparency: reduce) {
:root {
--glass-bg: hsla(0 0% 100% / 0.95);
--glass-bg-solid: hsla(0 0% 100% / 0.99);
--glass-bg-scrim: hsla(0 0% 100% / 0.99);
--glass-border: hsla(0 0% 0% / 0.14);
--glass-border-highlight: hsla(0 0% 0% / 0.1);
--glass-blur: 2px;
--glass-blur-heavy: 2px;
}
@media (prefers-color-scheme: dark) {
:root {
--glass-bg: hsla(220 20% 8% / 0.97);
--glass-bg-solid: hsla(220 20% 6% / 0.99);
--glass-bg-scrim: hsla(220 20% 6% / 0.99);
--glass-border: hsla(0 0% 100% / 0.14);
--glass-border-highlight: hsla(0 0% 100% / 0.1);
}
}
}
/* Base glass layer — use for cards, panels, overlays */
.glass {
/* Tinted fill plus a subtle top-left sheen to mimic physical glass */
background: linear-gradient(
145deg,
hsla(0 0% 100% / 0.22) 0%,
hsla(0 0% 100% / 0.06) 55%,
hsla(0 0% 100% / 0.14) 100%
),
var(--glass-bg);
backdrop-filter: blur(var(--glass-blur)) saturate(145%);
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(145%);
border: 1px solid var(--glass-border);
box-shadow:
inset 0 1px 0 0 var(--glass-border-highlight),
inset 0 0 0 1px hsla(0 0% 100% / 0.04),
var(--glass-shadow);
border-radius: 1rem;
}
/* More opaque glass — used behind dense text or form sections */
.glass-solid {
background: linear-gradient(
145deg,
hsla(0 0% 100% / 0.18) 0%,
hsla(0 0% 100% / 0.04) 55%,
hsla(0 0% 100% / 0.1) 100%
),
var(--glass-bg-solid);
backdrop-filter: blur(var(--glass-blur-heavy)) saturate(125%);
-webkit-backdrop-filter: blur(var(--glass-blur-heavy)) saturate(125%);
border: 1px solid var(--glass-border);
box-shadow:
inset 0 1px 0 0 var(--glass-border-highlight),
inset 0 0 0 1px hsla(0 0% 100% / 0.04),
var(--glass-shadow);
border-radius: 0.875rem;
}
/* Scrim gradient for text protection on glass surfaces */
.glass-scrim {
background: linear-gradient(
180deg,
var(--glass-bg-scrim) 0%,
color-mix(in oklab, var(--glass-bg-scrim) 85%, transparent) 100%
);
}
/* Fallback for browsers without backdrop-filter support */
@supports not (backdrop-filter: blur(12px)) {
.glass {
background: var(--glass-bg-solid);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
.glass-solid {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
}
/* Ambient background blobs — keep some definition so the glass can refract them */
.glass-ambient-blob {
position: absolute;
border-radius: 50%;
filter: blur(44px);
opacity: 0.85;
will-change: transform, opacity;
animation: glass-float 20s ease-in-out infinite alternate;
}
@media (prefers-reduced-motion: reduce) {
.glass-ambient-blob {
animation: none;
opacity: 0.6;
}
}
@keyframes glass-float {
0% {
transform: translate3d(0, 0, 0) scale(1);
}
100% {
transform: translate3d(30px, -40px, 0) scale(1.15);
}
}
/* Extra mesh layer behind the card: sharper color bands for stronger refraction */
.glass-ambient-mesh {
position: absolute;
inset: 0;
opacity: 0.7;
background: radial-gradient(
circle at 20% 30%,
var(--glass-ambient-1) 0%,
transparent 45%
),
radial-gradient(circle at 80% 20%, var(--glass-ambient-2) 0%, transparent 40%),
radial-gradient(circle at 60% 80%, var(--glass-ambient-3) 0%, transparent 45%),
radial-gradient(circle at 30% 75%, var(--glass-ambient-4) 0%, transparent 40%);
filter: blur(28px);
will-change: transform;
animation: glass-mesh-drift 30s ease-in-out infinite alternate;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.glass-ambient-mesh {
animation: none;
opacity: 0.5;
}
}
@keyframes glass-mesh-drift {
0% {
transform: scale(1) translate3d(0, 0, 0);
}
100% {
transform: scale(1.08) translate3d(-20px, 10px, 0);
}
}
/* Text stability helper */
.glass-text-stable {
text-shadow: var(--glass-text-shadow);
}
+2
View File
@@ -0,0 +1,2 @@
export { GlassAmbientBackground } from "./GlassAmbientBackground";
export { GlassCard } from "./GlassCard";
+48 -7
View File
@@ -1,5 +1,6 @@
@import "tailwindcss"; @import "tailwindcss";
@import "tw-animate-css"; @import "tw-animate-css";
@import "./glass/glass-theme.css";
@theme inline { @theme inline {
--font-sans: "Inter", "SF Pro Text", "SF Pro Display", "Helvetica Neue", "Segoe UI", --font-sans: "Inter", "SF Pro Text", "SF Pro Display", "Helvetica Neue", "Segoe UI",
@@ -25,6 +26,11 @@
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);
/* Glassmorphism tokens exposed to Tailwind */
--color-glass-bg: var(--glass-bg);
--color-glass-border: var(--glass-border);
--color-glass-shadow: var(--glass-shadow);
--radius-sm: calc(var(--radius) - 4px); --radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px); --radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius); --radius-lg: var(--radius);
@@ -33,7 +39,7 @@
@layer base { @layer base {
:root { :root {
--background: hsl(0 0% 100%); --background: hsl(220 30% 96%);
--foreground: hsl(222.2 84% 4.9%); --foreground: hsl(222.2 84% 4.9%);
--card: hsl(0 0% 100%); --card: hsl(0 0% 100%);
--card-foreground: hsl(222.2 84% 4.9%); --card-foreground: hsl(222.2 84% 4.9%);
@@ -53,6 +59,45 @@
--input: hsl(214.3 31.8% 91.4%); --input: hsl(214.3 31.8% 91.4%);
--ring: hsl(222.2 84% 4.9%); --ring: hsl(222.2 84% 4.9%);
--radius: 0.625rem; --radius: 0.625rem;
/* Page gradient visible behind the glass card */
--page-bg-gradient: linear-gradient(
135deg,
hsl(220 35% 93%) 0%,
hsl(250 30% 90%) 50%,
hsl(300 25% 92%) 100%
);
}
@media (prefers-color-scheme: dark) {
:root {
--background: hsl(222 47% 6%);
--foreground: hsl(210 40% 98%);
--card: hsl(220 20% 12%);
--card-foreground: hsl(210 40% 98%);
--popover: hsl(220 20% 12%);
--popover-foreground: hsl(210 40% 98%);
--primary: hsl(210 40% 98%);
--primary-foreground: hsl(222.2 47.4% 11.2%);
--secondary: hsl(217.2 32.6% 17.5%);
--secondary-foreground: hsl(210 40% 98%);
--muted: hsl(217.2 32.6% 17.5%);
--muted-foreground: hsl(215 20.2% 65.1%);
--accent: hsl(217.2 32.6% 17.5%);
--accent-foreground: hsl(210 40% 98%);
--destructive: hsl(0 62.8% 30.6%);
--destructive-foreground: hsl(210 40% 98%);
--border: hsl(217.2 32.6% 17.5%);
--input: hsl(217.2 32.6% 17.5%);
--ring: hsl(212.7 26.8% 83.9%);
--page-bg-gradient: linear-gradient(
135deg,
hsl(222 47% 6%) 0%,
hsl(250 35% 8%) 50%,
hsl(300 30% 7%) 100%
);
}
} }
*, *,
@@ -67,7 +112,7 @@
min-height: 100%; min-height: 100%;
font-family: var(--font-sans); font-family: var(--font-sans);
color: var(--color-foreground); color: var(--color-foreground);
background: var(--color-background); background: var(--page-bg-gradient);
} }
a { a {
@@ -97,11 +142,7 @@
width: 100%; width: 100%;
max-width: 460px; max-width: 460px;
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
border: 1px solid var(--color-border); /* Background, border and shadow are provided by the .glass utility. */
background: var(--color-card);
box-shadow:
0 1px 3px 0 rgb(0 0 0 / 0.1),
0 1px 2px -1px rgb(0 0 0 / 0.1);
} }
.kcHeaderClass { .kcHeaderClass {