Skip to main content

Glassmorphism Dashboard Cards in Oracle APEX

Oracle APEX

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.

Oracle APEX PL/SQL Glassmorphism CSS Animations Dashboard
The Beginning

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.

The default APEX regions do the job but they never feel exciting. I wanted to build something that actually made people stop and look.
Architecture

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.

Application
  |
  +-- 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.

Step 1

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.

SQL
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
);
💡 Make sure to grant INSERT on this table to your APEX schema. If you skip this, the Application Process will fail silently and you will have zero rows.
Step 2

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.

⚠️ I tried logging inside the Dynamic Content region itself first. That caused duplicate inserts because the region runs on partial page refresh too. Moving it to an Application Process fixed that completely.
PL/SQL
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;
Step 3

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.

🐛 I tried using a regular SQL Report region with custom CSS classes. It did not work because APEX wraps report output in its own table tags and overrides most of the layout. The Dynamic Content region gives you a completely clean slate.
Finally this approach worked: switching to Dynamic Content and building raw HTML inside a CLOB. You get complete control and APEX does not interfere with the structure at all.
PL/SQL
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;
Step 4

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.

🏠
Home
4,820
page views
↑ 12% vs last week
👥
Users
3,190
page views
↑ 8% vs last week
CSS
.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; }
Issues I Hit

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.
🔑 Always use APEX_ESCAPE.HTML() around any database-sourced text you put into the HTML output. Page names can contain characters that will break your markup if not escaped.
APEX Setup

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.
🚀 After about 5 to 10 page navigations across different pages, the cards should show real view counts with the trend indicators working correctly.
Final Thoughts

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.

Oracle APEX 23.2+ PL/SQL CLOB Output CSS backdrop-filter Dynamic Content Region Real-time Analytics

J
Jefith Shalin
Oracle APEX Developer · Building modern UIs on classic stacks

Comments

Popular posts from this blog

Screen Recorder in Oracle APEX (Single Page)

Oracle APEX Tutorial Screen Recorder in Oracle APEX : Single Page Build a fully functional browser-based screen recorder inside Oracle APEX using just one Static Content region and native JavaScript. No plugins, no external libraries, no server uploads required. ✍️ Why I Built This I was working on a client project where the support team needed to record screen issues and share them directly from the APEX application, without switching to any external tool. Installing third-party software was not an option on their machines, and every screen recorder extension required IT approval. That is when I thought: the browser already has everything we need. Why not build it right inside APEX? That idea turned into this. &#127916; Start / Stop Recording &#128065; Instant Preview ⬇️ One-click Download &#128266; Audio + Video ...

Sticky Notes Widget Inside Oracle APEX

Oracle APEX Project Building a Sticky Notes Widget in Oracle APEX How I built a fully draggable, color-coded, per-user sticky notes board using jQuery UI, APEX Ajax callbacks, and a bit of patience. Live Demo GitHub Repo Oracle APEX jQuery UI PL/SQL Ajax Callbacks JavaScript CSS Introduction Why I Built This I have been building internal tools on Oracle APEX for a while now, and one thing I always felt was missing was a place where users could quickly jot down thoughts without leaving the page. Think of it like a personal scratchpad that lives right inside the app. I had seen sticky note UIs in some Google products and I thought, how hard can this be in APEX? It turned out to be more interesting than I expected. There were a few wrong tu...

IG Drag Fill Series

Excel Drag Fill in Oracle APEX Interactive Grid Oracle APEX · Interactive Grid Excel-Like Drag & Fill in APEX IG "I spent way too long trying to get this working… and then one weird jQuery trick changed everything." Oracle APEX Interactive Grid jQuery JavaScript PL/SQL CSS Live Demo GitHub scroll The Backstory If you've used Excel for even ten minutes, you already know that little green handle at the corner of a selected cell. You drag it down, and boom, values fill across every row automatically. It's one of those features users love so much they don't even think about it. They just expect it everywhere. So when a ...