Building Glassmorphism Dashboard Cards in Oracle APEX
A full walkthrough of how I built animated, glassmorphism-style page view cards inside Oracle APEX using Dynamic Content regions, custom PL/SQL, and pure CSS animations.
Why I Wanted to Build This
I was working on a dashboard for an APEX application and the default report regions were just not cutting it visually. Every page looked the same, every chart was a plain table with some borders. I wanted something that actually looked modern, something that users would notice when they first opened the app.
I had seen glassmorphism cards on a few web portfolios and thought, why not bring that into Oracle APEX? It seemed like a good idea at the time. What I did not expect was how long it would take to get everything working properly inside the APEX framework without breaking the Universal Theme.
How the Component is Structured
Before jumping into code, here is how everything fits together. There are three main pieces. First, a custom log table that captures every page view. Second, a PL/SQL function inside a Dynamic Content region that reads that log and builds the HTML. Third, a CSS file loaded via the region's CSS URL property.
|
+-- apex_page_views (log table)
|
+-- Application Process: LOG_PAGE_VIEW
|
+-- Dashboard Page
|
+-- Region: Dynamic Content (PL/SQL fn)
+-- CSS URL: fonts.googleapis.com
+-- Inline CSS: glassmorphism styles
The key thing is that the PL/SQL function returns a CLOB of raw HTML. APEX just takes that and drops it into the page. You have full control over every div and every inline style.
Creating the Page View Log Table
The cards show real page view counts from the last 30 days. For this to work you need a table that logs every page visit. I created a simple table called apex_page_views. Nothing fancy, just enough columns to track who viewed which page and when.
CREATE TABLE apex_page_views (
id NUMBER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
app_id NUMBER,
page_id NUMBER,
page_name VARCHAR2(255),
viewed_by VARCHAR2(255),
viewed_on DATE DEFAULT SYSDATE
);
The Application Process for Logging
I added this as an Application Process that runs On Load for every page. It picks up the current page name from apex_application_pages and inserts a row. This way the logging happens automatically without touching individual pages.
BEGIN
INSERT INTO apex_page_views (
app_id,
page_id,
page_name,
viewed_by
)
SELECT
:APP_ID,
:APP_PAGE_ID,
p.page_name,
:APP_USER
FROM apex_application_pages p
WHERE p.application_id = :APP_ID
AND p.page_id = :APP_PAGE_ID;
COMMIT;
END;
The PL/SQL Function That Builds the Cards
This is the main piece. The Dynamic Content region calls a PL/SQL function that queries the log table, ranks pages by total views, and builds a complete HTML block. Every card is constructed line by line inside the loop.
One thing that caught me for a while was forgetting the RETURN l_html at the end. The region was rendering nothing and I spent almost an hour checking CSS before realising the function was not returning anything at all.
DECLARE
l_html CLOB := '';
CURSOR c_pages IS
SELECT
RANK() OVER (ORDER BY total_views DESC) AS page_rank,
CASE RANK() OVER (ORDER BY total_views DESC)
WHEN 1 THEN 'rank-1' ELSE '' END AS rank1_class,
'#' || RANK() OVER (ORDER BY total_views DESC)
|| CASE RANK() OVER (ORDER BY total_views DESC)
WHEN 1 THEN ' - TOP' ELSE '' END AS rank_label,
page_id,
page_name,
TO_CHAR(total_views,'FM999,999') AS view_count_fmt,
ROUND(total_views*100.0/SUM(total_views) OVER()) AS pct_of_total,
CASE RANK() OVER (ORDER BY total_views DESC)
WHEN 1 THEN 'rgba(99,55,255,0.30)'
WHEN 2 THEN 'rgba(0,183,255,0.25)'
WHEN 3 THEN 'rgba(0,230,160,0.22)'
WHEN 4 THEN 'rgba(251,146,60,0.25)'
WHEN 5 THEN 'rgba(244,63,94,0.22)'
ELSE 'rgba(163,230,53,0.20)'
END AS icon_bg,
CASE RANK() OVER (ORDER BY total_views DESC)
WHEN 1 THEN '#a78bfa' WHEN 2 THEN '#38bdf8'
WHEN 3 THEN '#34d399' WHEN 4 THEN '#fb923c'
WHEN 5 THEN '#f472b6' ELSE '#a3e635'
END AS accent_color,
CASE RANK() OVER (ORDER BY total_views DESC)
WHEN 1 THEN '#6337ff' WHEN 2 THEN '#0ea5e9'
WHEN 3 THEN '#059669' WHEN 4 THEN '#ea580c'
WHEN 5 THEN '#db2777' ELSE '#65a30d'
END AS glow_color,
CASE RANK() OVER (ORDER BY total_views DESC)
WHEN 1 THEN 'linear-gradient(90deg,#6337ff,#a78bfa)'
WHEN 2 THEN 'linear-gradient(90deg,#0ea5e9,#38bdf8)'
WHEN 3 THEN 'linear-gradient(90deg,#059669,#34d399)'
WHEN 4 THEN 'linear-gradient(90deg,#ea580c,#fb923c)'
WHEN 5 THEN 'linear-gradient(90deg,#db2777,#f472b6)'
ELSE 'linear-gradient(90deg,#65a30d,#a3e635)'
END AS bar_gradient,
CASE page_id
WHEN 1 THEN 'fa-home'
WHEN 2 THEN 'fa-users'
WHEN 3 THEN 'fa-bar-chart'
WHEN 4 THEN 'fa-file-text-o'
WHEN 5 THEN 'fa-cog'
WHEN 9999 THEN 'fa-sign-in'
ELSE 'fa-file-o'
END AS page_icon,
CASE WHEN week_current >= week_prior THEN 'up' ELSE 'down' END AS trend_class,
CASE WHEN week_current >= week_prior THEN '↑' ELSE '↓' END AS trend_arrow,
CASE
WHEN week_prior IS NULL OR week_prior = 0
THEN 'New this week'
ELSE ABS(ROUND((week_current - week_prior)*100.0 / week_prior)) || '% vs last week'
END AS trend_display
FROM (
SELECT
v.page_id, v.page_name,
COUNT(*) AS total_views,
SUM(CASE WHEN v.viewed_on >= TRUNC(SYSDATE,'IW')
THEN 1 ELSE 0 END) AS week_current,
SUM(CASE WHEN v.viewed_on >= TRUNC(SYSDATE,'IW')-7
AND v.viewed_on < TRUNC(SYSDATE,'IW')
THEN 1 ELSE 0 END) AS week_prior
FROM apex_page_views v
WHERE v.app_id = :APP_ID
AND v.viewed_on >= SYSDATE - 30
GROUP BY v.page_id, v.page_name
)
ORDER BY total_views DESC
FETCH FIRST 6 ROWS ONLY;
BEGIN
l_html := l_html || '<div class="gm-wrapper">';
l_html := l_html || '<div class="gm-orb gm-orb-1"></div>';
l_html := l_html || '<div class="gm-grid-bg"></div>';
l_html := l_html || '<div class="gm-header"><div>';
l_html := l_html || '<div class="gm-eyebrow">Page Analytics</div>';
l_html := l_html || '<h2 class="gm-title">Most Visited Pages</h2>';
l_html := l_html || '<p class="gm-subtitle">Last 30 days</p>';
l_html := l_html || '</div><div class="gm-live-badge">';
l_html := l_html || '<span class="gm-live-dot"></span>Live Tracking';
l_html := l_html || '</div></div>';
l_html := l_html || '<div class="gm-grid-cards">';
FOR r IN c_pages LOOP
l_html := l_html || '<a class="gm-card" href="f?p=' || :APP_ID || ':' || r.page_id || ':' || :APP_SESSION || '::NO::" style="text-decoration:none;">';
l_html := l_html || '<div class="gm-card-glow" style="background:' || r.glow_color || ';"></div>';
l_html := l_html || '<div class="gm-rank-badge ' || r.rank1_class || '">' || r.rank_label || '</div>';
l_html := l_html || '<div class="gm-icon-wrap" style="background:' || r.icon_bg || ';color:' || r.accent_color || ';">';
l_html := l_html || '<span class="fa ' || r.page_icon || '" aria-hidden="true"></span></div>';
l_html := l_html || '<div class="gm-page-name">' || APEX_ESCAPE.HTML(r.page_name) || '</div>';
l_html := l_html || '<div class="gm-count">' || r.view_count_fmt || '</div>';
l_html := l_html || '<div class="gm-count-label">page views</div>';
l_html := l_html || '<div class="gm-bar-track"><div class="gm-bar-fill" style="width:' || r.pct_of_total || '%;background:' || r.bar_gradient || ';"></div></div>';
l_html := l_html || '<div class="gm-trend ' || r.trend_class || '">';
l_html := l_html || '<span class="gm-trend-arrow">' || r.trend_arrow || '</span>';
l_html := l_html || '<span>' || r.trend_display || '</span></div>';
l_html := l_html || '<div class="gm-card-footer">';
l_html := l_html || '<span class="gm-pct-label">Share of total</span>';
l_html := l_html || '<span class="gm-pct-value" style="color:' || r.accent_color || ';">' || r.pct_of_total || '%</span>';
l_html := l_html || '</div></a>';
END LOOP;
l_html := l_html || '</div></div>';
RETURN l_html;
END;
The CSS for the Glass Effect
The glassmorphism look comes from three CSS properties working together: backdrop-filter: blur(), a semi-transparent background, and a light border. The blur is what creates the frosted glass feeling. Without it the card just looks like a plain div with low opacity.
One issue I faced was that backdrop-filter does not work unless there is a proper layered background behind the card. On a plain white page the blur has nothing to blur, so the glass effect disappears completely. The fix was making the wrapper a dark gradient container so the blur has something to work against.
.gm-card {
position: relative;
border-radius: 20px;
padding: 22px 20px 18px;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.14);
backdrop-filter: blur(24px) saturate(180%);
-webkit-backdrop-filter: blur(24px) saturate(180%);
transition:
transform 0.35s cubic-bezier(.34,1.56,.64,1),
background 0.30s ease,
border-color 0.30s ease,
box-shadow 0.35s ease;
overflow: hidden;
cursor: pointer;
}
.gm-card:hover {
transform: translateY(-8px) scale(1.02);
background: rgba(255,255,255,0.13);
border-color: rgba(255,255,255,0.32);
box-shadow:
0 20px 60px rgba(0,0,0,0.50),
0 0 0 1px rgba(255,255,255,0.08),
inset 0 1px 0 rgba(255,255,255,0.20);
}
@keyframes gm-card-entrance {
from { opacity:0; transform:translateY(40px) scale(0.94); filter:blur(8px); }
to { opacity:1; transform:translateY(0) scale(1); filter:blur(0); }
}
.gm-card { animation: gm-card-entrance 0.6s ease both; }
.gm-card:nth-child(1) { animation-delay: 0.15s; }
.gm-card:nth-child(2) { animation-delay: 0.25s; }
.gm-card:nth-child(3) { animation-delay: 0.35s; }
.gm-card:nth-child(4) { animation-delay: 0.45s; }
.gm-card:nth-child(5) { animation-delay: 0.55s; }
.gm-card:nth-child(6) { animation-delay: 0.65s; }
Problems and How I Fixed Them
There were a few real problems I had to sort out before everything looked right. Here are the ones that took the most time.
-
1
Universal Theme was overriding layout. APEX's default CSS was adding margins and padding inside my wrapper. I added
.gm-wrapper .t-Report { all: unset; }at the bottom of my inline CSS to cancel all of that out. - 2 Cards were not clickable as links. I first used a div for the card container but the whole area was not clickable as a page link. Changed the outer element to an anchor tag and navigation worked perfectly.
- 3 Trend arrow was showing literal HTML entities. I was passing the arrow through APEX_ESCAPE.HTML and it was double escaping. The fix was keeping the trend arrow outside the escaped section and building it directly in the concatenation.
- 4 Week over week comparison returned NULL for new pages. When a page had no views last week, week_prior was NULL and the percentage calculation crashed. Added an explicit NULL and zero check in the CASE expression to return 'New this week' instead.
Setting It Up in Your APEX App
Here is the exact setup inside the APEX builder. It is shorter than it looks.
- 1Run the CREATE TABLE DDL in SQL Workshop to create the log table.
- 2Go to Shared Components, Application Processes, create a process named LOG_PAGE_VIEW, set it to run On Load Before Header, paste the INSERT block.
- 3On your dashboard page, add a new region. Set the type to Dynamic Content. Paste the full DECLARE...BEGIN...END block into the PL/SQL Function Body field.
- 4In the region's CSS URL property paste the Google Fonts Outfit link.
- 5In the Inline CSS property paste the complete CSS block from the GitHub repo.
- 6Save and run the page. Navigate a few pages, then come back. The cards will start showing real counts.
What I Learned From This
The main thing I took from this is that Oracle APEX is way more flexible than most people think. You are not stuck with the default region styles. As long as you use Dynamic Content and build your HTML manually, you can produce anything a modern web developer can produce.
The CSS animations do not affect performance in any meaningful way. APEX pages are already loading a lot of JavaScript for the Universal Theme, a few CSS keyframes on top of that makes no real difference.
If you want to take this further, you could add a modal that shows a full view timeline for each page, or a toggle between bar and donut chart views. The log table already has all the data you need to build that out.
Comments
Post a Comment