How to Show a Dismissible Announcement Banner to All Users in Oracle APEX

Store announcements with start and end dates, show them on every page from the Global Page with the Alerts template, and let each user dismiss them in Oracle APEX.

An announcement banner tells every user about planned downtime, a new feature or a changed process the moment they open the application. It is more visible than an email, and users do not have to look for it on a news page.

A good banner also respects the user. It shows only between a start and an end date, and each user can close it once without hiding it for anyone else. This guide stores announcements in a table, shows them on every page from the Global Page, and saves each dismissal per user.

The guide How to Put an Oracle APEX Application in Maintenance Mode blocks the whole application and shows one message instead of the pages. This guide keeps the application running and shows messages above the page content, with scheduling and a Dismiss button for each user.

The guide How to Show Success, Error, and Confirm Messages Using apex.message in Oracle APEX shows short messages from JavaScript after an action. The messages here come from the database, are managed by administrators on their own page, and stay until each user dismisses them or they expire.

Step 1: Create the Tables

The example needs two tables. ANNOUNCEMENTS holds the messages with their severity and dates. ANNOUNCEMENT_DISMISSALS records which user closed which announcement:

Example:

create table announcements (
    announcement_id number generated by default on null as identity,
    title           varchar2(100)  not null,
    message         varchar2(1000) not null,
    severity        varchar2(10)   default on null 'INFO' not null,
    start_on        date           default on null sysdate not null,
    end_on          date           not null,
    created_by      varchar2(255)  default on null
                        coalesce(sys_context('APEX$SESSION', 'APP_USER'),
                                 sys_context('USERENV', 'SESSION_USER')) not null,
    created_on      date           default on null sysdate not null,
    constraint announcements_pk primary key (announcement_id),
    constraint announcements_severity_ck check (severity in ('INFO', 'SUCCESS', 'WARNING', 'DANGER')),
    constraint announcements_dates_ck check (end_on > start_on)
);

create index announcements_dates_ix on announcements (start_on, end_on);

create table announcement_dismissals (
    announcement_id number        not null,
    user_name       varchar2(255) not null,
    dismissed_on    date          default on null sysdate not null,
    constraint announcement_dismissals_pk primary key (announcement_id, user_name),
    constraint announcement_dismissals_fk foreign key (announcement_id)
        references announcements (announcement_id) on delete cascade
);

insert into announcements (title, message, severity, start_on, end_on)
values ('Planned downtime on Sunday',
        'The application will be offline on Sunday from 06:00 to 08:00 for a database upgrade. Please save your work before then.',
        'WARNING', sysdate - 1, sysdate + 7);

insert into announcements (title, message, severity, start_on, end_on)
values ('New report available',
        'The Shipping Dashboard now loads its regions in parallel. Open it from the navigation menu.',
        'INFO', sysdate - 2, sysdate + 14);

insert into announcements (title, message, severity, start_on, end_on)
values ('Holiday support hours',
        'Support desk hours were reduced during the holiday week.',
        'INFO', sysdate - 30, sysdate - 20);

insert into announcements (title, message, severity, start_on, end_on)
values ('Year-end freeze',
        'No new price changes after 20 December.',
        'DANGER', sysdate + 30, sysdate + 60);

commit;

select announcement_id, title, severity,
       to_char(start_on, 'YYYY-MM-DD HH24:MI') as starts,
       to_char(end_on, 'YYYY-MM-DD HH24:MI') as ends,
       case when sysdate >= start_on and sysdate < end_on then 'Y' else 'N' end as active
from   announcements
order  by announcement_id;

Output:

Table ANNOUNCEMENTS created.

Index ANNOUNCEMENTS_DATES_IX created.

Table ANNOUNCEMENT_DISMISSALS created.

1 row inserted.

1 row inserted.

1 row inserted.

1 row inserted.

Commit complete.

   ANNOUNCEMENT_ID TITLE                         SEVERITY    STARTS              ENDS                ACTIVE
__________________ _____________________________ ___________ ___________________ ___________________ _________
                 1 Planned downtime on Sunday    WARNING     2026-10-23 18:57    2026-10-31 18:57    Y
                 2 New report available          INFO        2026-10-22 18:57    2026-11-07 18:57    Y
                 3 Holiday support hours         INFO        2026-09-24 18:57    2026-10-04 18:57    N
                 4 Year-end freeze               DANGER      2026-11-23 18:57    2026-12-23 18:57    N

A few details in the tables:

  • ANNOUNCEMENTS_SEVERITY_CK allows INFO, SUCCESS, WARNING and DANGER. These match the four alert colors of the Universal Theme.
  • ANNOUNCEMENTS_DATES_CK makes sure every announcement ends after it starts.
  • SEVERITY, START_ON, CREATED_BY and CREATED_ON use DEFAULT ON NULL. A new announcement needs only a title, a message and an end date, and no trigger is required.
  • The primary key of ANNOUNCEMENT_DISMISSALS is the announcement plus the user name, so one user can dismiss one announcement only once. ON DELETE CASCADE removes the dismissals with their announcement.

The sample data has two active announcements, one expired announcement and one that starts in 30 days. The ACTIVE column shows which ones a user should see today.

Step 2: Create the Manage Announcements Page

Administrators need a page to write and schedule announcements. An editable interactive grid is enough for that. In App Builder, open the application and click Create Page. On the Component tab, select Interactive Grid:

Create a Page wizard with the Interactive Grid tile marked with a red box
Choose the Interactive Grid page type.

Set Page Number to 1080 and Name to Manage Announcements. Keep Page Mode Normal and Include Form Page off:

Create Interactive Grid wizard with Page Number 1080 and Name Manage Announcements marked with a red box
The page number and name.

Under Data Source, enter ANNOUNCEMENTS as Table / View Name and turn Editing Enabled on:

Data Source section with Table / View Name ANNOUNCEMENTS and Editing Enabled on marked with a red box
An editable grid on the ANNOUNCEMENTS table.

Under Navigation, turn Use Breadcrumb off. Keep Use Navigation on with Create a new entry, set Icon to fa-bullhorn and click Next:

Navigation section with Use Breadcrumb off and Icon fa-bullhorn marked with red boxes
A navigation menu entry with a bullhorn icon.

On the Primary Key screen, select ANNOUNCEMENT_ID (Number) as Primary Key Column 1 and click Create Page:

Primary Key screen with Primary Key Column 1 ANNOUNCEMENT_ID marked with a red box
The primary key of the grid.

The wizard creates the page with the grid, an automatic row processing process, and a Manage Announcements entry in the navigation menu.

Step 3: Set Up the Grid Columns

The wizard makes every column a text field or date picker. A few changes make the grid easier and safer to use. In Page Designer, expand the Manage Announcements region and its Columns, and select SEVERITY.

Set Type to Select List. Under List of Values, set Type to Static Values and enter four values: Info (INFO), Success (SUCCESS), Warning (WARNING) and Danger (DANGER). Turn Display Null Value off:

SEVERITY column with Type Select List, List of Values Type Static Values, the four static values and Display Null Value off marked with red boxes
Severity as a select list of the four alert types.

Under Default, set Type to Static and Static Value to INFO, so new rows start as Info:

Default group of the SEVERITY column with Type Static and Static Value INFO marked with a red box
New announcements default to Info.

Select START_ON. Set Format Mask to DD-MON-YYYY HH24:MI, so administrators can enter a time, and turn Value Required off. An empty Start On is filled with the current date and time by the DEFAULT ON NULL of the column:

START_ON column with Format Mask DD-MON-YYYY HH24:MI and Value Required off marked with red boxes
Start On with a time, and optional.

Select END_ON and set the same Format Mask, but keep Value Required on. Then select CREATED_BY and set Type to Display Only. Do the same for CREATED_ON, with Format Mask DD-MON-YYYY HH24:MI:

CREATED_BY column with Type Display Only marked with a red box
The audit columns are filled by the table defaults.

Step 4: Add a Validation for the Dates

The check constraint already rejects an end date before the start date. Without a validation, however, the user sees the raw ORA-02290 error. In a first test, saving a row with End On before Start On showed "ORA-02290: check constraint (APXSOL.ANNOUNCEMENTS_DATES_CK) violated".

Open the Processing tab, right-click Validating and choose Create Validation. Set Name to End On after Start On and Editable Region to Manage Announcements. Set Type to Expression, Language PL/SQL, and enter this expression:

Example:

:END_ON is null or to_date(:END_ON, 'DD-MON-YYYY HH24:MI') > nvl(to_date(:START_ON, 'DD-MON-YYYY HH24:MI'), sysdate)

Set Error Message to "End On must be later than Start On (or later than now when Start On is empty)." and Associated Column to END_ON:

Validation with Name End On after Start On, Editable Region Manage Announcements, Type Expression, the PL/SQL Expression, Error Message and Associated Column END_ON marked with red boxes
A row validation for the two date columns.

Grid columns are bound as text in the column's format mask, so the expression converts them with the same mask. An empty End On is left to the Value Required check of the column. The default Execution Scope, For Created and Modified Rows, skips deleted rows:

Server-side Condition group of the validation with Execution Scope For Created and Modified Rows marked with a red box
The validation runs only for new and changed rows.

Step 5: Restrict the Page to Administrators

Select Page 1080 at the top of the Rendering tree. Under Security, set Authorization Scheme to Is Administrator:

Page 1080 Security group with Authorization Scheme Is Administrator marked with a red box
Only administrators can open the page.

Is Administrator is an authorization scheme of the example application. Use the scheme that marks your own administrators. APEX also hides the navigation menu entry from users who fail the scheme.

Step 6: Add the Banner Region to the Global Page

The Global Page (page 0) renders its regions on every page of the application. That makes it the right place for the banner.

Open page 0 in Page Designer, right-click Body and choose Create Region. Set Name to Announcements and Type to Classic Report. Under Source, keep Type SQL Query and enter this query:

Example:

select a.announcement_id,
       lower(a.severity) as alert_type,
       a.title           as alert_title,
       a.message         as alert_desc,
       null              as alert_action
from   announcements a
where  sysdate >= a.start_on
and    sysdate <  a.end_on
and    not exists (
         select 1
         from   announcement_dismissals d
         where  d.announcement_id = a.announcement_id
         and    d.user_name = :APP_USER)
order  by case a.severity
            when 'DANGER'  then 1
            when 'WARNING' then 2
            else 3
          end,
          a.start_on desc
Global Page in Page Designer with region Name Announcements, Type Classic Report, Source Type SQL Query and the SQL Query marked with red boxes
The banner query on the Global Page.

The query returns only the announcements that are active now and that the current user has not dismissed. The column aliases ALERT_TYPE, ALERT_TITLE, ALERT_DESC and ALERT_ACTION are the names the Alerts report template expects (Step 8). Danger messages come first, then warnings, then the newest of the rest.

Under Layout, set Sequence to 1, so the banner comes before the regions of each page. Under Appearance, set Template to Blank with Attributes (No Grid), so no region frame is drawn around the alerts. Under Advanced, set HTML DOM ID to announcements:

Layout Sequence 1, Appearance Template Blank with Attributes (No Grid) and Advanced HTML DOM ID announcements marked with red boxes
The banner sits at the top of every page without a region frame.

Step 7: Show the Banner Only on Normal Pages

Global Page regions also render inside modal dialog pages. In a first test without a condition, the banner appeared twice when a modal dialog was open: once on the page and once inside the dialog:

Select Customer modal dialog showing the same two announcements again above its report
First test: the banner was repeated inside the modal dialog.

The banner should also stay off the login page, where no user is signed in yet. Under Server-side Condition, set Type to Expression, Language PL/SQL, and enter this expression:

Example:

apex_authentication.is_authenticated
and apex_page.get_page_mode(p_application_id => :APP_ID, p_page_id => :APP_PAGE_ID) = 'NORMAL'
Server-side Condition of the Announcements region with Type Expression, Language PL/SQL and the PL/SQL Expression marked with a red box
Signed-in users on normal pages only.

APEX_PAGE.GET_PAGE_MODE returns NORMAL for normal pages and MODAL for modal dialog pages. The region now shows only on normal pages of signed-in users.

Step 8: Use the Alerts Report Template

Click the Attributes tab of the region. Under Appearance, set Template to Alerts. Under Pagination, set Type to No Pagination (Show All Rows). Under Messages, clear When No Data Found, so nothing shows when there are no announcements:

Region Attributes with Appearance Template Alerts, Pagination Type No Pagination and an empty When No Data Found marked with red boxes
Each row becomes a Universal Theme alert.

The Alerts report template is part of the Universal Theme. It draws each row as an alert and uses four columns:

  • ALERT_TYPE: info, success, warning or danger. It sets the color and icon, so the query returns the severity in lowercase.
  • ALERT_TITLE: the heading of the alert.
  • ALERT_DESC: the text below the heading.
  • ALERT_ACTION: the content on the right side, used here for the Dismiss button.

Step 9: Add the Dismiss Button Column

Expand the region's Columns. Set Type of ANNOUNCEMENT_ID to Hidden. Then select ALERT_ACTION and, under Column Formatting, enter this HTML Expression:

Example:

<button type="button" class="t-Button t-Button--noLabel t-Button--icon t-Button--small js-dismiss-announcement" data-id="#ANNOUNCEMENT_ID#" title="Dismiss" aria-label="Dismiss"><span class="t-Icon fa fa-times" aria-hidden="true"></span></button>
ALERT_ACTION column with the HTML Expression for the Dismiss button marked with a red box
An X button with the announcement ID in a data attribute.

The js-dismiss-announcement class lets a dynamic action find every Dismiss button. The data-id attribute carries the ID of the announcement. ALERT_TITLE and ALERT_DESC keep Escape Special Characters on, so HTML typed into a message is shown as text and is not run.

Step 10: Add the Hidden Item

Right-click the Announcements region and choose Create Page Item. Set Name to P0_ANNOUNCEMENT_ID and Type to Hidden. Turn Value Protected off, because JavaScript sets its value. Under Session State, set Storage to Per Request (Memory Only):

Item P0_ANNOUNCEMENT_ID with Type Hidden, Value Protected off and Storage Per Request (Memory Only) marked with red boxes
The item that carries the clicked announcement ID to the server.

Per Request storage keeps the value only for the request that submits it. Nothing about the clicked button is left in session state.

Step 11: Create the Dismiss Dynamic Action

Open the Dynamic Actions tab, right-click Events and choose Create Dynamic Action. Set Name to Dismiss Announcement. Under When, set Event to Click, Selection Type to jQuery Selector and jQuery Selector to .js-dismiss-announcement. Under Execution, set Event Scope to Dynamic and Static Container to body:

Dynamic action Dismiss Announcement with Event Scope Dynamic, Static Container body, Event Click, Selection Type jQuery Selector and jQuery Selector .js-dismiss-announcement marked with red boxes
A click handler that survives region refreshes.

With Event Scope Dynamic, the click handler sits on the body element. Buttons that appear later, such as the ones redrawn by the refresh at the end of this dynamic action, react to clicks too.

Change the first True action to Set Value. Set Set Type to JavaScript Expression with this expression, and Item(s) to P0_ANNOUNCEMENT_ID:

Example:

this.triggeringElement.dataset.id
Set Value action with Set Type JavaScript Expression, the expression this.triggeringElement.dataset.id and Item P0_ANNOUNCEMENT_ID marked with red boxes
Copy the ID of the clicked button into the hidden item.

Add a second True action of type Execute Server-side Code. Enter this PL/SQL code, set Items to Submit to P0_ANNOUNCEMENT_ID, and turn Wait For Result on:

Example:

insert into announcement_dismissals (announcement_id, user_name)
select a.announcement_id, :APP_USER
from   announcements a
where  a.announcement_id = to_number(:P0_ANNOUNCEMENT_ID default null on conversion error)
and    sysdate >= a.start_on
and    sysdate <  a.end_on
and    not exists (
         select 1
         from   announcement_dismissals d
         where  d.announcement_id = a.announcement_id
         and    d.user_name = :APP_USER);
Execute Server-side Code action with the PL/SQL Code, Items to Submit P0_ANNOUNCEMENT_ID and Wait For Result on marked with red boxes
Save the dismissal for the current user.

The insert uses :APP_USER, so a user can only dismiss announcements for themselves. It saves nothing for an unknown, expired or future announcement, or for one the user has already dismissed. TO_NUMBER with DEFAULT NULL ON CONVERSION ERROR turns a forged non-numeric value into NULL instead of an error.

Add a third True action of type Refresh, with Selection Type Region and Region Announcements:

Refresh action with Selection Type Region and Region Announcements marked with red boxes
Redraw the banner without the dismissed announcement.

Wait For Result on the server-side action makes the refresh start only after the insert has finished, so the redrawn banner no longer contains the dismissed announcement.

Step 12: Test the Banner

SALES_USER, a Reader in the example application, signed in and opened the home page. Both active announcements appeared, the warning first. The expired and the future announcement did not appear:

Home page with a Planned downtime on Sunday warning and a New report available info banner, with the Dismiss button of the warning marked with a red box
Two active announcements for SALES_USER.

SALES_USER clicked the X on the warning. One Ajax call saved the dismissal and a second one refreshed the region. The warning disappeared without a page reload, and it stayed hidden after reloading the page:

Home page of SALES_USER with only the New report available banner left
The dismissed warning is gone for SALES_USER.

ORDER_EDITOR then signed in. This user still saw both announcements, because the dismissal belongs to SALES_USER only:

Home page of ORDER_EDITOR still showing both announcements
Other users still see the warning.

Next, SALES_USER tried to dismiss announcements that were not shown. In the browser console, a fake Dismiss button with data-id 3 (the expired announcement) was added and clicked:

Example:

var b = document.createElement('button');
b.className = 'js-dismiss-announcement';
b.dataset.id = '3';
document.body.appendChild(b);
b.click();
b.remove();

The same was repeated with 4 (the future announcement), abc and 999, one after the other.

Each click ran the dynamic action and the server returned no error. The insert found no matching active announcement, so no row was saved, and the banner kept showing one announcement. The SQL check in Step 13 confirms it.

Then DEMO_DEV, an administrator, opened Manage Announcements and tried to save a row with End On before Start On. The validation stopped it with the friendly message:

Manage Announcements grid with an error notification and the End On cell of the new row marked with a red box
The date validation in the grid.

DEMO_DEV then added a correct row: Office closed on Monday, Success, End On 05-NOV-2026 18:00 and an empty Start On. The message contained HTML bold tags on purpose:

Manage Announcements grid with the new Office closed on Monday row and the Save button marked with red boxes
A new announcement, with Start On left empty.

After Save and a page reload, the new announcement appeared in green at once, on the admin page too. Start On was filled with the current date and time, and Created By with DEMO_DEV. The bold tags were shown as plain text:

Banner with the new green Office closed on Monday announcement and the grid row with Start On, Created By DEMO_DEV and Created On filled in
The table defaults filled Start On and the audit columns.

SALES_USER then opened the Manage Announcements URL directly. APEX refused the page, and the menu entry was not shown to this user:

APEX error page with Only administrators can use this page and Access denied by Page security check
Readers cannot manage announcements.

Finally, DEMO_DEV opened the Select Customer modal dialog from the New Order page. The banner showed once on the page behind the dialog, and not inside the dialog:

Select Customer modal dialog without any banner, over a page that shows three announcements
After Step 7, modal dialogs show no banner.

Step 13: Check the Data in SQL

This query lists the saved dismissals:

Example:

select d.announcement_id, a.title, d.user_name,
       to_char(d.dismissed_on, 'YYYY-MM-DD HH24:MI') as dismissed_on
from   announcement_dismissals d
join   announcements a on a.announcement_id = d.announcement_id
order  by d.announcement_id, d.user_name;

Output:

   ANNOUNCEMENT_ID TITLE                         USER_NAME     DISMISSED_ON
__________________ _____________________________ _____________ ___________________
                 1 Planned downtime on Sunday    SALES_USER    2026-10-24 19:07

Only one row exists: SALES_USER dismissed announcement 1. The forged clicks for 3, 4, abc and 999 saved nothing. This query shows the announcements after the tests:

Example:

select announcement_id, title, severity, created_by,
       to_char(start_on, 'YYYY-MM-DD HH24:MI') as starts,
       to_char(end_on, 'YYYY-MM-DD HH24:MI') as ends,
       case when sysdate >= start_on and sysdate < end_on then 'Y' else 'N' end as active
from   announcements
order  by announcement_id;

Output:

   ANNOUNCEMENT_ID TITLE                         SEVERITY    CREATED_BY    STARTS              ENDS                ACTIVE
__________________ _____________________________ ___________ _____________ ___________________ ___________________ _________
                 1 Planned downtime on Sunday    WARNING     APXSOL        2026-10-23 18:57    2026-10-31 18:57    Y
                 2 New report available          INFO        APXSOL        2026-10-22 18:57    2026-11-07 18:57    Y
                 3 Holiday support hours         INFO        APXSOL        2026-09-24 18:57    2026-10-04 18:57    N
                 4 Year-end freeze               DANGER      APXSOL        2026-11-23 18:57    2026-12-23 18:57    N
                23 Office closed on Monday       SUCCESS     DEMO_DEV      2026-10-24 19:11    2026-11-05 18:00    Y

The new announcement has CREATED_BY DEMO_DEV from the DEFAULT ON NULL of the column. The sample rows show the schema owner, because they were inserted from SQL outside an APEX session. Its ID is 23 and not 5; gaps in identity values are normal.

Things to Know

  • Global Page regions render on every page of the application, also inside modal dialogs. Use a server-side condition to choose where the banner appears.
  • The Global Page region also appears in the Rendering tree of every page in Page Designer, marked [Global Page].
  • The dates are compared with SYSDATE, the clock of the database server. In this example the database runs in UTC, so enter start and end times in the server time zone, or store TIMESTAMP WITH TIME ZONE values instead.
  • The banner query runs on every page view, so keep it small. The index on START_ON and END_ON and the primary key of the dismissals table support it.
  • Keep Escape Special Characters on for the title and message columns. HTML typed by an administrator is then shown as text.
  • Delete an announcement to remove its dismissals as well (ON DELETE CASCADE). To show a dismissed message again, create a new announcement.
  • The Alerts template takes ALERT_TYPE values in lowercase: info, success, warning and danger.

Related Guides

Conclusion

A dismissible announcement banner in Oracle APEX needs two tables, one classic report on the Global Page with the Alerts template, and one dynamic action. Table defaults, a check constraint and a grid validation handle the admin side without triggers.

The tests showed the banner for each user until that user dismissed it, ignored expired, future and forged IDs, and escaped HTML in messages. Remember the server-side condition, or the banner also appears inside modal dialogs and on the login page.

Get more Vinish.dev tutorials on Google

See my articles more often when you search Google.

Vinish Kapoor
Vinish Kapoor

An Oracle ACE, author of four books on Oracle APEX, SQL and PL/SQL, and Oracle Forms, and a software developer building Oracle database applications since 2001.

guest

0 Comments
Oldest
Newest Most Voted